diff --git a/.claude/docs/data-sources-api-research.md b/.claude/docs/data-sources-api-research.md index 60e9f9ff..7c9da004 100644 --- a/.claude/docs/data-sources-api-research.md +++ b/.claude/docs/data-sources-api-research.md @@ -386,7 +386,7 @@ let createdAt: FieldValue = .date(Date()) // Reference to another record let categoryRef: FieldValue = .reference( - FieldValue.Reference( + Reference( recordName: "category-123", action: nil // or "DELETE_SELF" for cascade delete ) @@ -394,7 +394,7 @@ let categoryRef: FieldValue = .reference( // Location let location: FieldValue = .location( - FieldValue.Location( + Location( latitude: 37.7749, longitude: -122.4194 ) diff --git a/.claude/docs/protocol-extraction-continuation.md b/.claude/docs/protocol-extraction-continuation.md index bf6679ae..c3a8905a 100644 --- a/.claude/docs/protocol-extraction-continuation.md +++ b/.claude/docs/protocol-extraction-continuation.md @@ -441,8 +441,8 @@ struct XcodeVersionRecord: CloudKitRecord { var version: String var buildNumber: String var releaseDate: Date - var swiftVersion: FieldValue.Reference // Reference to SwiftVersionRecord - var macOSVersion: FieldValue.Reference // Reference to another record + var swiftVersion: Reference // Reference to SwiftVersionRecord + var macOSVersion: Reference // Reference to another record // Implement protocol requirements... } diff --git a/.codefactor.yml b/.codefactor.yml new file mode 100644 index 00000000..5ba2c8a0 --- /dev/null +++ b/.codefactor.yml @@ -0,0 +1,2 @@ +exclude: + - "Scripts/mermaid-to-pptx.py" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6fed9bab..3410c9cb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { - "name": "Swift 6.2", - "image": "swift:6.2", + "name": "Swift 6.3", + "image": "swift:6.3", "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": "false", diff --git a/.devcontainer/swift-6.1/devcontainer-lock.json b/.devcontainer/swift-6.1/devcontainer-lock.json new file mode 100644 index 00000000..b7abe24f --- /dev/null +++ b/.devcontainer/swift-6.1/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.7", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4", + "integrity": "sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.5", + "resolved": "ghcr.io/devcontainers/features/git@sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251", + "integrity": "sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251" + } + } +} diff --git a/.devcontainer/swift-6.3-nightly/devcontainer.json b/.devcontainer/swift-6.3-nightly/devcontainer.json deleted file mode 100644 index 57c29fee..00000000 --- a/.devcontainer/swift-6.3-nightly/devcontainer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Swift 6.3 Nightly Development Container", - "image": "swift:6.3-nightly-jammy", - "features": { - "ghcr.io/devcontainers/features/common-utils:2": {} - }, - "customizations": { - "vscode": { - "extensions": [ - "sswg.swift-lang" - ] - } - }, - "postCreateCommand": "swift --version" -} diff --git a/.devcontainer/swift-6.3/devcontainer.json b/.devcontainer/swift-6.3/devcontainer.json new file mode 100644 index 00000000..3410c9cb --- /dev/null +++ b/.devcontainer/swift-6.3/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.3", + "image": "swift:6.3", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "sswg.swift-lang" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 00000000..069f32e9 --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,29 @@ +name: Setup mise tools +description: >- + Restore (or build + save) the mise tool cache and put the binaries on PATH. + Implemented as a composite action so the cache scope is the caller job's + scope — reusable workflows scope caches separately, which silently breaks + hand-off between a setup job and a consumer lint job. + +runs: + using: composite + steps: + - name: Cache mise tools + id: mise-cache + uses: actions/cache@v4 + with: + path: ~/.local/share/mise/installs + key: mise-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} + restore-keys: | + mise-v2-${{ runner.os }}-${{ runner.arch }}- + - name: Install mise tools (cache miss) + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: jdx/mise-action@v4 + with: + cache: false + - name: Configure PATH for cached mise tools + if: steps.mise-cache.outputs.cache-hit == 'true' + uses: jdx/mise-action@v4 + with: + install: false + cache: false diff --git a/.github/workflows/MistDemo-Integration.yml b/.github/workflows/MistDemo-Integration.yml new file mode 100644 index 00000000..df0dd9ac --- /dev/null +++ b/.github/workflows/MistDemo-Integration.yml @@ -0,0 +1,203 @@ +# MistDemo Integration Runs +# +# Live end-to-end runs against the real CloudKit container +# (iCloud.com.brightdigit.MistDemo). Triggered on push to main and +# release branches, and via manual workflow_dispatch (used to re-run +# after rotating CLOUDKIT_WEB_AUTH_TOKEN). +# +# Required repo secrets: +# CLOUDKIT_API_TOKEN - Web Services API token +# CLOUDKIT_WEB_AUTH_TOKEN - Web-auth token (rotate when stale; manual) +# CLOUDKIT_KEY_ID - Server-to-server key ID +# CLOUDKIT_PRIVATE_KEY_BASE64 - Server-to-server PEM, base64-encoded +# (base64 -w0 key.pem) +# CLOUDKIT_CONTAINER_ID - iCloud.com.brightdigit.MistDemo +# +# Two-job design: +# 1) `build` runs in `swift:6.3-noble`, installs the Swift Static +# Linux SDK (musl), and produces a self-contained statically +# linked mistdemo binary. +# 2) `integration` runs on plain `ubuntu-24.04` (no Swift toolchain, +# no LD_LIBRARY_PATH needed) and executes the live CloudKit +# phases against the static binary. +# +# Secrets are NOT exposed to fork pull requests — there is no +# pull_request trigger on this workflow by design. + +name: MistDemo Integration + +on: + push: + branches: + - main + - 'v*.*.*' + workflow_dispatch: + +concurrency: + group: mistdemo-integration-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build static mistdemo + runs-on: ubuntu-24.04 + # Pin to an exact Swift patch — the Static Linux SDK URL + checksum + # below are tied to this same version. Bump together. + container: swift:6.3.2-noble + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Install Swift Static Linux SDK + run: | + set -euo pipefail + swift sdk install \ + https://download.swift.org/swift-6.3.2-release/static-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz \ + --checksum 3fd798bef6f4408f1ea5a6f94ce4d4052830c4326ab85ebc04f983f01b3da407 + swift sdk list + + - name: swift build --swift-sdk x86_64-swift-linux-musl -c release + working-directory: Examples/MistDemo + run: swift build -c release --swift-sdk x86_64-swift-linux-musl + + - name: Verify static linkage + working-directory: Examples/MistDemo + run: | + set -euo pipefail + BIN=.build/x86_64-swift-linux-musl/release/mistdemo + ls -lh "$BIN" + # A statically linked binary either reports "not a dynamic + # executable" (musl) or "statically linked" (glibc). Anything + # else means we picked up shared deps and broke the goal. + LDD_OUTPUT=$(ldd "$BIN" 2>&1 || true) + echo "$LDD_OUTPUT" + if ! echo "$LDD_OUTPUT" | grep -qE 'not a dynamic executable|statically linked'; then + echo "::error::Binary has dynamic dependencies; static link failed" + exit 1 + fi + + - uses: actions/upload-artifact@v4 + with: + name: mistdemo + path: Examples/MistDemo/.build/x86_64-swift-linux-musl/release/mistdemo + retention-days: 1 + # Single executable; default zip compression is fine. + + integration: + name: Live CloudKit Integration + needs: build + runs-on: ubuntu-24.04 + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/download-artifact@v4 + with: + name: mistdemo + + - name: Prep binary + run: | + set -euo pipefail + chmod +x ./mistdemo + mkdir -p integration-logs + # Smoke check the binary before touching the network. + ./mistdemo --help >/dev/null + + - name: Decode server-to-server private key + env: + CLOUDKIT_PRIVATE_KEY_BASE64: ${{ secrets.CLOUDKIT_PRIVATE_KEY_BASE64 }} + run: | + set -euo pipefail + KEY_PATH="$RUNNER_TEMP/cloudkit_s2s.pem" + printf '%s' "$CLOUDKIT_PRIVATE_KEY_BASE64" | base64 -d > "$KEY_PATH" + chmod 600 "$KEY_PATH" + # Defensive: mask the decoded PEM line-by-line so it can never + # land in step logs. + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < "$KEY_PATH" + echo "CLOUDKIT_PRIVATE_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + + - name: test-public + timeout-minutes: 10 + env: + CLOUDKIT_API_TOKEN: ${{ secrets.CLOUDKIT_API_TOKEN }} + CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} + CLOUDKIT_CONTAINER_ID: ${{ secrets.CLOUDKIT_CONTAINER_ID }} + CLOUDKIT_ENVIRONMENT: development + run: | + set -o pipefail + ./mistdemo test-public \ + --record-count 5 \ + --asset-size 50 \ + 2>&1 | tee integration-logs/test-public.log + + - name: test-private + timeout-minutes: 10 + env: + CLOUDKIT_API_TOKEN: ${{ secrets.CLOUDKIT_API_TOKEN }} + CLOUDKIT_WEB_AUTH_TOKEN: ${{ secrets.CLOUDKIT_WEB_AUTH_TOKEN }} + CLOUDKIT_CONTAINER_ID: ${{ secrets.CLOUDKIT_CONTAINER_ID }} + CLOUDKIT_ENVIRONMENT: development + run: | + set -o pipefail + ./mistdemo test-private \ + --record-count 5 \ + --asset-size 50 \ + 2>&1 | tee integration-logs/test-private.log + + - name: demo-errors + timeout-minutes: 10 + env: + CLOUDKIT_API_TOKEN: ${{ secrets.CLOUDKIT_API_TOKEN }} + CLOUDKIT_WEB_AUTH_TOKEN: ${{ secrets.CLOUDKIT_WEB_AUTH_TOKEN }} + CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} + CLOUDKIT_CONTAINER_ID: ${{ secrets.CLOUDKIT_CONTAINER_ID }} + CLOUDKIT_ENVIRONMENT: development + run: | + set -o pipefail + ./mistdemo demo-errors --scenario all \ + 2>&1 | tee integration-logs/demo-errors.log + # demo-errors exits 0 by design — assert all scenarios actually + # ran by checking for the per-scenario completion markers. + for marker in "401" "404" "409"; do + grep -q "$marker" integration-logs/demo-errors.log || { + echo "::error::demo-errors did not exercise scenario $marker" + exit 1 + } + done + + - name: demo-in-filter + timeout-minutes: 10 + env: + CLOUDKIT_API_TOKEN: ${{ secrets.CLOUDKIT_API_TOKEN }} + CLOUDKIT_WEB_AUTH_TOKEN: ${{ secrets.CLOUDKIT_WEB_AUTH_TOKEN }} + CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} + CLOUDKIT_CONTAINER_ID: ${{ secrets.CLOUDKIT_CONTAINER_ID }} + CLOUDKIT_ENVIRONMENT: development + run: | + set -o pipefail + ./mistdemo demo-in-filter \ + 2>&1 | tee integration-logs/demo-in-filter.log + + - name: Upload integration logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-logs + path: integration-logs/ + retention-days: 14 + if-no-files-found: warn + + - name: Cleanup decoded private key + if: always() + run: | + if [ -n "${CLOUDKIT_PRIVATE_KEY_PATH:-}" ] && [ -f "$CLOUDKIT_PRIVATE_KEY_PATH" ]; then + rm -f "$CLOUDKIT_PRIVATE_KEY_PATH" + fi diff --git a/.github/workflows/MistDemo.yml b/.github/workflows/MistDemo.yml new file mode 100644 index 00000000..d7a4ee70 --- /dev/null +++ b/.github/workflows/MistDemo.yml @@ -0,0 +1,323 @@ +name: MistDemo +on: + push: + branches: + - main + paths: + - 'Examples/MistDemo/**' + - 'Sources/MistKit/**' + - 'Package.swift' + - '.github/workflows/MistDemo.yml' + pull_request: + paths: + - 'Examples/MistDemo/**' + - 'Sources/MistKit/**' + - 'Package.swift' + - '.github/workflows/MistDemo.yml' + +concurrency: + group: mistdemo-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + PACKAGE_NAME: MistDemo + WORKING_DIR: Examples/MistDemo + +jobs: + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + ubuntu-type: ${{ steps.matrix.outputs.ubuntu-type }} + steps: + - id: check + name: Determine matrix scope + run: | + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + run: | + # MistDemo's Package.swift declares swift-tools-version: 6.2, + # so Swift 6.1 is not supported. + if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=["6.2","6.3"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=["6.3"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" + fi + + build-ubuntu: + name: Build on Ubuntu + needs: configure + runs-on: ubuntu-latest + container: swift:${{ matrix.swift }}-${{ matrix.os }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + type: ${{ matrix.type }} + wasmtime-version: "40.0.2" + wasm-swift-flags: >- + -Xcc -D_WASI_EMULATED_SIGNAL + -Xcc -D_WASI_EMULATED_MMAN + -Xlinker -lwasi-emulated-signal + -Xlinker -lwasi-emulated-mman + working-directory: ${{ env.WORKING_DIR }} + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + fi + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true + - uses: sersoft-gmbh/swift-coverage-action@v5 + if: steps.build.outputs.contains-code-coverage == 'true' + id: coverage-files + with: + fail-on-empty-output: true + search-paths: ${{ env.WORKING_DIR }}/.build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: mistdemo-swift-${{ matrix.swift }}-${{ matrix.os }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + build-windows: + name: Build on Windows + needs: configure + runs-on: ${{ matrix.runs-on }} + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + runs-on: [windows-2022, windows-2025] + # MistDemo's Package.swift declares swift-tools-version: 6.2, + # so Swift 6.1 is not supported. + # TODO: re-add swift-6.2-release once the swift-testing 6.2 + + # Windows parallel-runner crash is resolved (test process exits + # 1 with no diagnostic output after fanning out parallel tests; + # 6.3 is unaffected). + swift: + - version: swift-6.3-release + build: 6.3-RELEASE + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + working-directory: ${{ env.WORKING_DIR }} + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: mistdemo-swift-${{ matrix.swift.version }},windows + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + os: windows + swift_project: MistDemo + + build-android: + name: Build on Android + needs: configure + runs-on: ubuntu-latest + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + # MistDemo's Package.swift declares swift-tools-version: 6.2, + # so Swift 6.1 is not supported. + swift: + - version: "6.2" + - version: "6.3" + android-api-level: [33, 34] + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }} + type: android + android-swift-version: ${{ matrix.swift.version }} + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: true + working-directory: ${{ env.WORKING_DIR }} + # Note: Code coverage is not supported on Android builds + # The Swift Android SDK does not include LLVM coverage tools (llvm-profdata, llvm-cov) + + # Minimal macOS builds — always runs (SPM + iOS) + build-macos: + name: Build on macOS + runs-on: macos-26 + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # SPM build + - xcode: "/Applications/Xcode_26.4.app" + + # iOS build + - type: ios + xcode: "/Applications/Xcode_26.4.app" + deviceName: "iPhone 17 Pro" + osVersion: "26.4.1" + download-platform: true + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + working-directory: ${{ env.WORKING_DIR }} + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: ${{ env.WORKING_DIR }}/.build + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('mistdemo-{0}{1}', matrix.type, matrix.osVersion) || 'mistdemo-spm-macos' }} + + # Full macOS platform builds — only on main, semver branches, and PRs targeting them + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + runs-on: macos-26 + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # macOS + - type: macos + xcode: "/Applications/Xcode_26.4.app" + + # watchOS + - type: watchos + xcode: "/Applications/Xcode_26.4.app" + deviceName: "Apple Watch Ultra 3 (49mm)" + osVersion: "26.4" + download-platform: true + + # tvOS + - type: tvos + xcode: "/Applications/Xcode_26.4.app" + deviceName: "Apple TV" + osVersion: "26.4" + download-platform: true + + # visionOS + - type: visionos + xcode: "/Applications/Xcode_26.4.app" + deviceName: "Apple Vision Pro" + osVersion: "26.4.1" + download-platform: true + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + working-directory: ${{ env.WORKING_DIR }} + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: ${{ env.WORKING_DIR }}/.build + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('mistdemo-{0}{1}', matrix.type, matrix.osVersion) || 'mistdemo-spm-macos' }} + + lint: + name: Linting + runs-on: ubuntu-latest + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-macos-platforms, build-windows, build-android] + # Shared with MistKit's lint job: serialize across workflows so a cold + # cache on a mise.toml bump only triggers one rebuild, not a race. + concurrency: + group: lint-tools-${{ github.head_ref || github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-tools + - name: Lint + run: | + set -e + ./Scripts/lint.sh diff --git a/.github/workflows/MistKit.yml b/.github/workflows/MistKit.yml index 77e8e226..85e55cee 100644 --- a/.github/workflows/MistKit.yml +++ b/.github/workflows/MistKit.yml @@ -1,40 +1,95 @@ name: MistKit on: push: - branches-ignore: - - '*WIP' + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + env: - PACKAGE_NAME: MistKit + PACKAGE_NAME: MistKit + jobs: + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + ubuntu-type: ${{ steps.matrix.outputs.ubuntu-type }} + steps: + - id: check + name: Determine matrix scope + run: | + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + run: | + if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" + fi + build-ubuntu: name: Build on Ubuntu + needs: configure runs-on: ubuntu-latest - container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + container: swift:${{ matrix.swift.version }}-${{ matrix.os }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: - os: [noble, jammy] - swift: - - version: "6.1" - - version: "6.2" - - version: "6.3" - nightly: true - type: ["", "wasm", "wasm-embedded"] + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} exclude: - # Exclude Swift 6.1 from wasm builds + # Exclude Swift 6.1 from wasm builds (not supported) - swift: { version: "6.1" } type: "wasm" - swift: { version: "6.1" } type: "wasm-embedded" - # Exclude Swift 6.3 from wasm builds - - swift: { version: "6.3", nightly: true } - type: "wasm" - - swift: { version: "6.3", nightly: true } - type: "wasm-embedded" steps: - - uses: actions/checkout@v4 - - uses: brightdigit/swift-build@v1.5.0 + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 id: build with: type: ${{ matrix.type }} @@ -44,24 +99,35 @@ jobs: -Xcc -D_WASI_EMULATED_MMAN -Xlinker -lwasi-emulated-signal -Xlinker -lwasi-emulated-mman - - uses: sersoft-gmbh/swift-coverage-action@v4 + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + fi + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true + - uses: sersoft-gmbh/swift-coverage-action@v5 if: steps.build.outputs.contains-code-coverage == 'true' id: coverage-files with: fail-on-empty-output: true - name: Upload coverage to Codecov if: steps.build.outputs.contains-code-coverage == 'true' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true - flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && 'nightly' || '' }} - verbose: true - token: ${{ secrets.CODECOV_TOKEN }} - files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + flags: swift-${{ matrix.swift.version }}-${{ matrix.os }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-windows: name: Build on Windows + needs: configure runs-on: ${{ matrix.runs-on }} - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: @@ -71,40 +137,43 @@ jobs: build: 6.1-RELEASE - version: swift-6.2-release build: 6.2-RELEASE + - version: swift-6.3-release + build: 6.3-RELEASE steps: - - uses: actions/checkout@v4 - - uses: brightdigit/swift-build@v1.5.0 + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 id: build with: windows-swift-version: ${{ matrix.swift.version }} windows-swift-build: ${{ matrix.swift.build }} - name: Upload coverage to Codecov if: steps.build.outputs.contains-code-coverage == 'true' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true flags: swift-${{ matrix.swift.version }},windows - verbose: true + verbose: true token: ${{ secrets.CODECOV_TOKEN }} os: windows swift_project: MistKit - # files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-android: name: Build on Android + needs: configure runs-on: ubuntu-latest - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: swift: - version: "6.1" - version: "6.2" + - version: "6.3" android-api-level: [33, 34] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Free disk space - if: matrix.build-only == false - uses: jlumbroso/free-disk-space@main + uses: jlumbroso/free-disk-space@v1.3.1 with: tool-cache: false android: false @@ -113,100 +182,131 @@ jobs: large-packages: true docker-images: true swap-storage: true - - uses: brightdigit/swift-build@v1.5.0 + - uses: brightdigit/swift-build@v1 with: - scheme: ${{ env.PACKAGE_NAME }} type: android android-swift-version: ${{ matrix.swift.version }} android-api-level: ${{ matrix.android-api-level }} android-run-tests: true # Note: Code coverage is not supported on Android builds # The Swift Android SDK does not include LLVM coverage tools (llvm-profdata, llvm-cov) + + # Minimal macOS builds — always runs (SPM + iOS) build-macos: name: Build on macOS - env: - PACKAGE_NAME: MistKit - runs-on: ${{ matrix.runs-on }} + runs-on: macos-26 if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: include: - # SPM Build Matrix - - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + # SPM build + - xcode: "/Applications/Xcode_26.4.app" + + # iOS build + - type: ios + xcode: "/Applications/Xcode_26.4.app" + deviceName: "iPhone 17 Pro" + osVersion: "26.4.1" + download-platform: true + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + # Full macOS platform builds — only on main, semver branches, and PRs targeting them + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + runs-on: ${{ matrix.runs-on }} + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # Additional SPM Xcode versions (older toolchains for compat) - runs-on: macos-15 xcode: "/Applications/Xcode_16.4.app" - runs-on: macos-15 xcode: "/Applications/Xcode_16.3.app" - # macOS Build Matrix + # macOS - type: macos runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" - - # iOS Build Matrix - - type: ios - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" - deviceName: "iPhone 17 Pro" - osVersion: "26.2" - download-platform: true - + xcode: "/Applications/Xcode_26.4.app" + + # iOS — older Xcode for backward compat - type: ios runs-on: macos-15 xcode: "/Applications/Xcode_16.3.app" deviceName: "iPhone 16" osVersion: "18.4" download-platform: true - - - # watchOS Build Matrix + + # watchOS - type: watchos runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.2" + osVersion: "26.4" download-platform: true - # tvOS Build Matrix + # tvOS - type: tvos runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple TV" - osVersion: "26.2" + osVersion: "26.4" download-platform: true - # visionOS Build Matrix + # visionOS - type: visionos runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Vision Pro" - osVersion: "26.2" + osVersion: "26.4.1" download-platform: true - steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v6 - name: Build and Test id: build - uses: brightdigit/swift-build@v1.5.0 + uses: brightdigit/swift-build@v1 with: - scheme: ${{ env.PACKAGE_NAME }} + scheme: ${{ env.PACKAGE_NAME }}-Package type: ${{ matrix.type }} xcode: ${{ matrix.xcode }} deviceName: ${{ matrix.deviceName }} osVersion: ${{ matrix.osVersion }} download-platform: ${{ matrix.download-platform }} - - # Common Coverage Steps + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true - name: Process Coverage if: steps.build.outputs.contains-code-coverage == 'true' - uses: sersoft-gmbh/swift-coverage-action@v4 - + uses: sersoft-gmbh/swift-coverage-action@v5 - name: Upload Coverage if: steps.build.outputs.contains-code-coverage == 'true' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} @@ -214,31 +314,16 @@ jobs: lint: name: Linting runs-on: ubuntu-latest - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} - needs: [build-ubuntu, build-macos, build-windows, build-android] - env: - MINT_PATH: .mint/lib - MINT_LINK_PATH: .mint/bin + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-macos-platforms, build-windows, build-android] + # Shared with MistDemo's lint job: serialize across workflows so a cold + # cache on a mise.toml bump only triggers one rebuild, not a race. + concurrency: + group: lint-tools-${{ github.head_ref || github.ref }} + cancel-in-progress: false steps: - - uses: actions/checkout@v4 - - name: Cache mint - id: cache-mint - uses: actions/cache@v4 - env: - cache-name: cache - with: - path: | - .mint - Mint - key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} - restore-keys: | - ${{ runner.os }}-mint- - - name: Install mint - if: steps.cache-mint.outputs.cache-hit == '' - run: | - git clone https://github.com/yonaskolb/Mint.git - cd Mint - swift run mint install yonaskolb/mint + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-tools - name: Lint run: | set -e diff --git a/.github/workflows/check-unsafe-flags.yml b/.github/workflows/check-unsafe-flags.yml index ac6e8170..348f4430 100644 --- a/.github/workflows/check-unsafe-flags.yml +++ b/.github/workflows/check-unsafe-flags.yml @@ -14,7 +14,7 @@ jobs: image: swift:latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install jq run: | diff --git a/.github/workflows/cleanup-caches.yml b/.github/workflows/cleanup-caches.yml new file mode 100644 index 00000000..f0124e2c --- /dev/null +++ b/.github/workflows/cleanup-caches.yml @@ -0,0 +1,29 @@ +name: Cleanup Branch Caches +on: + delete: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Cleanup caches for deleted branch + uses: actions/github-script@v9 + with: + script: | + const ref = `refs/heads/${context.payload.ref}`; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + }); + for (const cache of caches.data.actions_caches) { + console.log(`Deleting cache: ${cache.key}`); + await github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }); + } + console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e03d307d..2b3b13fe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,12 +24,10 @@ on: jobs: analyze: name: Analyze - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners - # Consider using larger runners for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-15') || 'ubuntu-latest' }} + # CodeQL Swift analysis requires macOS runners — Linux is not supported + # ("Swift analysis is only supported on macOS runner images"). Other languages + # can run on Linux, hence the conditional. + runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: actions: read @@ -47,19 +45,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 - + uses: actions/checkout@v6 + - name: Setup Xcode - run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + if: matrix.language == 'swift' + run: sudo xcode-select -s /Applications/Xcode_26.4.app/Contents/Developer - name: Verify Swift Version + if: matrix.language == 'swift' run: | swift --version swift package --version # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -77,6 +77,6 @@ jobs: swift build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index e298ffdc..ed403c33 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -11,7 +11,7 @@ jobs: test-examples: name: Test ${{ matrix.example }} on Ubuntu runs-on: ubuntu-latest - container: swift:6.2 + container: swift:6.3 if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Build and Test ${{ matrix.example }} uses: brightdigit/swift-build@v1 diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml index cdd57d62..4ab0b691 100644 --- a/.github/workflows/swift-source-compat.yml +++ b/.github/workflows/swift-source-compat.yml @@ -11,7 +11,6 @@ jobs: name: Test Swift ${{ matrix.container }} For Source Compatibility Suite runs-on: ubuntu-latest if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} - continue-on-error: ${{ contains(matrix.container, 'nightly') }} strategy: fail-fast: false @@ -19,13 +18,13 @@ jobs: container: - swift:6.1 - swift:6.2 - - swiftlang/swift:nightly-6.3-noble + - swift:6.3 container: ${{ matrix.container }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Test Swift 6.x For Source Compatibility run: swift build --disable-sandbox --verbose --configuration release diff --git a/.gitignore b/.gitignore index 46a5ec69..6506fa44 100644 --- a/.gitignore +++ b/.gitignore @@ -191,3 +191,5 @@ dev-debug.log # Task files # tasks.json # tasks/ +.claude/scheduled_tasks.lock +build diff --git a/.swift-format b/.swift-format index d5fd1870..5c31a3e1 100644 --- a/.swift-format +++ b/.swift-format @@ -29,7 +29,7 @@ "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, - "FileScopedDeclarationPrivacy" : true, + "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, diff --git a/.swiftlint.yml b/.swiftlint.yml index f766fc9f..7f5af019 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -108,6 +108,11 @@ line_length: closure_body_length: - 50 - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 identifier_name: excluded: - id @@ -117,7 +122,7 @@ excluded: - .build - Mint - Examples - - Sources/MistKit/Generated + - Sources/MistKitOpenAPI - Package.swift indentation_width: indentation_width: 2 @@ -133,4 +138,10 @@ disabled_rules: - trailing_comma - opening_brace - optional_data_string_conversion - - pattern_matching_keywords \ No newline at end of file + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 305f3d0c..657ffea7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,15 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -MistKit is a Swift Package for Server-Side and Command-Line Access to CloudKit Web Services. This is a fresh rewrite on the `claude` branch using modern Swift features and best practices. +MistKit is a Swift Package for Server-Side and Command-Line Access to CloudKit Web Services. It targets cross-platform Swift (including Linux, WASI, and Windows) using modern Swift concurrency and code generated from Apple's CloudKit Web Services OpenAPI specification. ## Key Project Context - **Purpose**: Provides a Swift interface to CloudKit Web Services (REST API) rather than the CloudKit framework -- **Target Platforms**: Cross-platform including Linux, server-side Swift, and command-line tools -- **Current Branch**: `claude` - A modern rewrite leveraging latest Swift advancements +- **Target Platforms**: Cross-platform including macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows +- **Default Branch**: `main` - **API Reference**: The `openapi.yaml` file contains the OpenAPI 3.0.3 specification for Apple's CloudKit Web Services -- **Repository State**: Fresh start with OpenAPI spec as the foundation for implementation +- **Code Generation**: Generated client code lives in `Sources/MistKitOpenAPI/` (its own target/product) and is committed +- **Targets/products**: `MistKit` (curated wrapper) and `MistKitOpenAPI` (raw generated client + types, `public`). `import MistKit` for the curated API; add `import MistKitOpenAPI` only to reach raw generated types ## Development Commands @@ -50,7 +51,7 @@ swift package generate-xcodeproj # Or manually with swift-openapi-generator swift run swift-openapi-generator generate \ - --output-directory Sources/MistKit/Generated \ + --output-directory Sources/MistKitOpenAPI \ --config openapi-generator-config.yaml \ openapi.yaml ``` @@ -66,14 +67,15 @@ swift test --parallel # Show test output swift test --verbose -# Format code (requires swift-format installation) -swift-format -i -r Sources/ Tests/ +# Format + lint +# swift-format, swiftlint, periphery, and swift-openapi-generator are pinned +# in mise.toml — do NOT invoke them from PATH directly. Run them THROUGH mise: +mise exec -- swift-format -i -r Sources/ Tests/ +mise exec -- swiftlint # lint +mise exec -- swiftlint --fix # auto-fix -# Lint code (requires swiftlint installation) -swiftlint - -# Auto-fix linting issues -swiftlint --fix +# Or run the full lint pipeline (build + swiftlint + header.sh + periphery): +./Scripts/lint.sh ``` ### MistDemo Commands @@ -89,13 +91,17 @@ swift run mistdemo --help swift run mistdemo auth-token swift run mistdemo current-user swift run mistdemo query +swift run mistdemo lookup swift run mistdemo create swift run mistdemo update +swift run mistdemo modify +swift run mistdemo delete swift run mistdemo upload-asset swift run mistdemo lookup-zones swift run mistdemo fetch-changes swift run mistdemo demo-in-filter -swift run mistdemo test-integration +swift run mistdemo demo-errors +swift run mistdemo test-public swift run mistdemo test-private # Run with specific configuration @@ -109,7 +115,7 @@ swift run mistdemo --config-file ~/.mistdemo/config.json query MistKit uses separate types for requests and responses at the OpenAPI schema level to accurately model CloudKit's asymmetric API behavior: **Type Layers:** -1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (Sources/MistKit/FieldValue.swift) +1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (`Sources/MistKit/Models/FieldValues/FieldValue.swift`) 2. **API Request Layer**: `FieldValueRequest` - No type field, CloudKit infers type from value structure 3. **API Response Layer**: `FieldValueResponse` - Optional type field for explicit type information @@ -127,8 +133,8 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev - `Components.Schemas.RecordResponse` - Records in response bodies **Conversion:** -- Request conversion: `Extensions/OpenAPI/Components+FieldValue.swift` converts domain `FieldValue` → `FieldValueRequest` -- Response conversion: `Service/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue` +- Request conversion: `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` converts domain `FieldValue` → `FieldValueRequest` +- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue` ### Modern Swift Features to Utilize - Swift Concurrency (async/await) for all network operations @@ -141,46 +147,60 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev ``` MistKit/ ├── Sources/ -│ └── MistKit/ -│ ├── Generated/ # Auto-generated OpenAPI client code (not committed) -│ └── MistKitClient.swift # Main client wrapper +│ ├── MistKit/ # Curated wrapper (CloudKitService, domain types, auth) +│ └── MistKitOpenAPI/ # Generated OpenAPI client + types (public, committed) ├── Tests/ │ └── MistKitTests/ ├── Scripts/ -│ └── generate-openapi.sh # Script to generate OpenAPI code +│ └── generate-openapi.sh # Script to generate OpenAPI code → Sources/MistKitOpenAPI/ ├── openapi.yaml # CloudKit Web Services OpenAPI specification └── openapi-generator-config.yaml # Configuration for code generation ``` ### CloudKitService Operations -`CloudKitService` operations are split across focused extension files: +`CloudKitService` operations are split across focused extension files (all paths relative to `Sources/MistKit/CloudKitService/`): | File | Operations | |------|-----------| -| `CloudKitService+Operations.swift` | `queryRecords`, `lookupRecords`, `modifyRecords` | +| `CloudKitService+Initialization.swift` | initializer overloads (API token, web auth token, server-to-server) | +| `CloudKitService+Operations.swift` | `queryRecords`, `queryAllRecords`, `lookupRecords` | +| `CloudKitService+WriteOperations.swift` | `modifyRecords`, `createRecord`, `updateRecord`, `deleteRecord` | | `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` | +| `CloudKitService+ModifyZones.swift` | `modifyZones(_:database:)` | | `CloudKitService+SyncOperations.swift` | `fetchRecordChanges(recordType:syncToken:)`, `fetchAllRecordChanges(recordType:syncToken:)` | -| `CloudKitService+UserOperations.swift` | `fetchCurrentUser()`, `discoverUserIdentities(lookupInfos:)` | -| `CloudKitService+AssetOperations.swift` | `uploadAssets` | -| `CloudKitService+WriteOperations.swift` | `requestAssetUploadURL`, `uploadAssetData` | +| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()` *(unavailable — pending #28)*, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) | +| `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` | +| `CloudKitService+AssetUpload.swift` | `uploadAssetData` | +| `CloudKitService+RecordManaging.swift` | record-managing convenience surface | +| `CloudKitService+Classification.swift` | operation classification (batch sync result tracking) | +| `CloudKitService+ErrorHandling.swift` | error mapping helpers | **Sync/Change Operations:** - `fetchRecordChanges(recordType:syncToken:)` → `/records/changes` — returns `RecordChangesResult` with `records`, `syncToken`, `moreComing` - `fetchAllRecordChanges(recordType:syncToken:)` — convenience wrapper that auto-paginates using `moreComing` - `fetchZoneChanges(syncToken:)` → `/zones/changes` — returns `ZoneChangesResult` - `lookupZones(zoneIDs:)` → `/zones/lookup` — returns `[ZoneInfo]` -- `discoverUserIdentities(lookupInfos:)` → `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]` +- `discoverUserIdentities(lookupInfos:)` → POST `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]` + +**User-Identity Operations (public DB + web-auth required):** +- `fetchCaller()` → `/users/caller` — returns `UserInfo`. Replaces deprecated `fetchCurrentUser()` / `users/current`. Only valid against the public database with web-auth credentials. +- `discoverAllUserIdentities()` → GET `/users/discover` — returns `[UserIdentity]` for every discoverable user in the caller's address book. +- `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`. +- `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`. -**Result Types (Sources/MistKit/Service/):** +In MistDemo, integration runs targeting these endpoints use `PhaseContext.userContextService` (a public+web-auth `CloudKitService`) which is built from `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` regardless of the primary `--database` selection. The `DatabaseConfiguration` / `AuthenticationCredentials` types in `Examples/MistDemo/Sources/MistDemoKit/Configuration/` enforce valid database+auth combinations at construction time. + +**Result Types (Sources/MistKit/Models/ and Sources/MistKit/Models/Zones/):** +- `QueryResult` — `records: [RecordInfo]`, `continuationMarker: String?` - `RecordChangesResult` — `records: [RecordInfo]`, `syncToken: String?`, `moreComing: Bool` -- `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?` -- `UserIdentity` — `userRecordName: String?`, `nameComponents: NameComponents?` -- `UserIdentityLookupInfo` — `emailAddress: String?`, `phoneNumber: String?` +- `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?`, `moreComing: Bool` +- `UserIdentity` — `userRecordName: String?`, `nameComponents: NameComponents?`, `lookupInfo: UserIdentityLookupInfo?` +- `UserIdentityLookupInfo` — `emailAddress: String?`, `phoneNumber: String?`, `userRecordName: String?` - `NameComponents` — full personal name parts (givenName, familyName, nickname, etc.) **Protocols:** -- `RecordTypeIterating` (`Sources/MistKit/Protocols/RecordTypeIterating.swift`) — `forEach(_ action:)` to iterate over CloudKit record types; used by `fetchAllRecordChanges` +- `RecordTypeIterating` (`Sources/MistKit/RecordManagement/RecordTypeIterating.swift`) — `forEach(_ action:)` to iterate over CloudKit record types; used by `fetchAllRecordChanges` ### Key Design Principles 1. **Protocol-Oriented**: Define protocols for all major components (TokenManager, NetworkClient, etc.) @@ -189,33 +209,28 @@ MistKit/ 4. **Sendable Compliance**: Ensure all types are Sendable for concurrency safety ### Logging -MistKit uses [swift-log](https://github.com/apple/swift-log) for cross-platform logging support, enabling usage on macOS, Linux, Windows, and other platforms. +MistKit uses [swift-log](https://github.com/apple/swift-log) for cross-platform logging. The package emits to four labeled subsystems; consumers install a `LogHandler` and choose verbosity via `logLevel`. -**Key Logging Components:** -- `MistKitLogger` - Centralized logging infrastructure with subsystems for `api`, `auth`, and `network` -- Environment-based privacy control via `MISTKIT_DISABLE_LOG_REDACTION` environment variable -- `SecureLogging` utilities for token masking and safe message formatting -- Structured logging in `LoggingMiddleware` for HTTP request/response debugging (DEBUG builds only) +**Subsystems** (declared in `Sources/MistKit/Extensions/Logger+Subsystem.swift`): -**Logging Subsystems:** -```swift -MistKitLogger.api // CloudKit API operations -MistKitLogger.auth // Authentication and token management -MistKitLogger.network // Network operations -``` +| Label | Use | +|-------|-----| +| `com.brightdigit.MistKit.api` | CloudKit API operations | +| `com.brightdigit.MistKit.auth` | Authentication and token management | +| `com.brightdigit.MistKit.network` | Network errors | +| `com.brightdigit.MistKit.middleware` | HTTP request/response traces (debug-level) | -**Helper Methods:** +**Internal usage** (inside MistKit): ```swift -MistKitLogger.logError(_:logger:shouldRedact:) // Error level -MistKitLogger.logWarning(_:logger:shouldRedact:) // Warning level -MistKitLogger.logInfo(_:logger:shouldRedact:) // Info level -MistKitLogger.logDebug(_:logger:shouldRedact:) // Debug level +let logger = Logger(subsystem: .api) +logger.debug("…") // protocol detail +logger.warning("…") +logger.error("…") ``` -**Privacy Controls:** -- By default, logs use `SecureLogging.safeLogMessage()` to redact sensitive information -- Set `MISTKIT_DISABLE_LOG_REDACTION=1` to disable redaction for debugging -- Tokens, keys, and secrets are automatically masked in logged messages +**For consumers:** install a `LogHandler` (e.g. `StreamLogHandler.standardOutput`) via `LoggingSystem.bootstrap` and set the level per-subsystem. Protocol traces — request/response bodies, headers, query params — are emitted at `.debug`. The middleware guards expensive work (1 MiB body collection, query-param parsing) behind `logger.logLevel <= .debug`, so the default `.info` level pays no overhead. + +There is no built-in redaction. Sensitive data (tokens, raw bodies) appears only at `.debug`; control exposure via `logLevel`. ### Asset Upload Transport Design @@ -245,12 +260,12 @@ Asset uploads use `URLSession.shared` directly rather than the injected `ClientT **Implementation Details:** - AssetUploader type: `(Data, URL) async throws -> (statusCode: Int?, data: Data)` -- Defined in: `Sources/MistKit/Core/AssetUploader.swift` -- URLSession extension: `Sources/MistKit/Extensions/URLSession+AssetUpload.swift` -- Upload orchestration: `Sources/MistKit/Service/CloudKitService+WriteOperations.swift` - - `uploadAssets()` - Complete two-step upload workflow - - `requestAssetUploadURL()` - Step 1: Get CDN upload URL - - `uploadAssetData()` - Step 2: Upload binary data to CDN +- Defined in: `Sources/MistKit/Models/AssetUploading/AssetUploader.swift` +- URLSession extension: `Sources/MistKit/Models/AssetUploading/URLSession+AssetUpload.swift` +- Upload orchestration: + - `uploadAssets()` - Complete two-step upload workflow → `Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift` + - `requestAssetUploadURL()` - Step 1: Get CDN upload URL → `Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift` + - `uploadAssetData()` - Step 2: Upload binary data to CDN → `Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift` **Future Consideration:** A `ClientTransport` extension could provide a generic upload method, but would need to: @@ -262,22 +277,43 @@ A `ClientTransport` extension could provide a generic upload method, but would n `FilterBuilder` is split across extension files for maintainability: -- `Sources/MistKit/Helpers/FilterBuilder.swift` — core comparators (EQUALS, NOT_EQUALS, LESS_THAN, etc.) and IN/NOT_IN -- `Sources/MistKit/Helpers/FilterBuilder+StringFilters.swift` — string-specific: `beginsWith`, `notBeginsWith`, `containsAllTokens` -- `Sources/MistKit/Helpers/FilterBuilder+ListMemberFilters.swift` — list-specific: `listContains`, etc. +- `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift` — core comparators (EQUALS, NOT_EQUALS, LESS_THAN, etc.) and IN/NOT_IN +- `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+StringFilters.swift` — string-specific: `beginsWith`, `notBeginsWith`, `containsAllTokens` +- `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+ListMemberFilters.swift` — list-specific: `listContains`, etc. **IN/NOT_IN serialization:** Uses `ListValuePayload` (`Components.Schemas.ListValuePayload`) to wrap array values. The fix in v1.0.0-alpha.5 ensures the correct `value` key structure is used when serializing list comparators. ### CloudKit Web Services Integration - Base URL: `https://api.apple-cloudkit.com` - Authentication: - - **Public database**: `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH` → server-to-server signing - - **Private database**: `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web authentication -- All operations should reference the OpenAPI spec in `cloudkit-api-openapi.yaml` + - **Public database**: caller picks per-call via `PublicAuthPreference` carried on `Database.public(_:)`. Either `.requires(.serverToServer)` (key-pair signing — needs `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH`) or `.requires(.webAuth)` (user-attributed — needs `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN`). Use `.prefers(_:)` to fall back to whichever cred is configured. + - **Private / Shared database**: always `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web-auth (CloudKit rejects S2S on these scopes). +- All operations should reference the OpenAPI spec in `openapi.yaml` - URL Pattern: `/database/{version}/{container}/{environment}/{database}/{operation}` -- Supported databases: `public`, `private`, `shared` +- Supported databases: `Database.public(PublicAuthPreference)`, `Database.private`, `Database.shared` - Environments: `development`, `production` +### Per-call attribution for `.public` + +`Database` carries the signing choice when targeting public: + +```swift +public enum Database { + case `public`(PublicAuthPreference) + case `private` + case shared +} +``` + +`PublicAuthPreference` is constructed via two factories — never via the (internal) memberwise init: + +- `.prefers(.serverToServer)` — try S2S, fall back to web-auth/API-token if S2S isn't configured. +- `.prefers(.webAuth)` — try web-auth, fall back to S2S if web-auth isn't configured. +- `.requires(.serverToServer)` — must use S2S; throw `missingCredentials(.preferenceRequired)` otherwise. +- `.requires(.webAuth)` — must use web-auth; throw `missingCredentials(.preferenceRequired)` otherwise. + +There is **no default** on the operation `database:` parameter — every call must pick explicitly. The `requiresUserContext` flag on the dispatcher is gone; user-context routes (`users/*`) pass `.public(.requires(.webAuth))` directly. See `Sources/MistKit/Authentication/PublicAuthPreference.swift` and `Sources/MistKit/Authentication/Credentials+TokenManager.swift`. + ### Testing Strategy - Use Swift Testing framework (`@Test` macro) for all tests - Unit tests for all public APIs @@ -296,18 +332,19 @@ A `ClientTransport` extension could provide a generic upload method, but would n - Mock uploaders should simulate realistic HTTP responses **Test Files:** -- `Tests/MistKitTests/Service/CloudKitServiceUploadTests+*.swift` -- `Tests/MistKitTests/Service/AssetUploadTokenTests.swift` +- `Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+*.swift` +- `Tests/MistKitTests/Models/AssetUploading/AssetUploadTokenTests.swift` ### MistDemo Integration Test Runner -`Examples/MistDemo/Sources/MistDemo/Integration/` provides a live end-to-end test suite that runs against a real CloudKit container: +`Examples/MistDemo/Sources/MistDemoKit/Integration/` provides a live end-to-end test suite that runs against a real CloudKit container: - `IntegrationTestRunner.swift` — orchestrates all operations (query, create, update, lookup, upload, fetchChanges, lookupZones, discoverUserIdentities) - `IntegrationTestData.swift` — seed data for integration tests - `IntegrationTestError.swift` — typed errors for test failures +- `IntegrationTest.swift`, `PhasedIntegrationTest.swift`, and `Tests/` subdirectory — protocol-based phase pipeline introduced in #283 -Run via `swift run mistdemo test-integration` or `swift run mistdemo test-private` (private database variant). Both commands require valid CloudKit credentials in the config file. +Run via `swift run mistdemo test-public` or `swift run mistdemo test-private` (private database variant). Both commands require valid CloudKit credentials in the config file. ## Important Implementation Notes @@ -319,9 +356,9 @@ Run via `swift run mistdemo test-integration` or `swift run mistdemo test-privat ## OpenAPI-Driven Development -The Swift package uses Apple's swift-openapi-generator to create type-safe client code from the OpenAPI specification. Generated code is placed in `Sources/MistKit/Generated/` and should not be committed to version control. +The Swift package uses Apple's swift-openapi-generator to create type-safe client code from the OpenAPI specification. Generated code is placed in the standalone `MistKitOpenAPI` target at `Sources/MistKitOpenAPI/` (`accessModifier: public` so downstream code can `import MistKitOpenAPI` as an escape hatch). It is committed. -> **IMPORTANT: Never manually edit files in `Sources/MistKit/Generated/`.** These files are auto-generated from `openapi.yaml`. Any manual edits will be lost when code is regenerated. Instead, modify `openapi.yaml` and regenerate using `./Scripts/generate-openapi.sh`. +> **IMPORTANT: Never manually edit files in `Sources/MistKitOpenAPI/`.** These files are auto-generated from `openapi.yaml`. Any manual edits will be lost when code is regenerated. Instead, modify `openapi.yaml` and regenerate using `./Scripts/generate-openapi.sh`. The `openapi.yaml` file serves as the source of truth for: - All available endpoints and their HTTP methods @@ -333,7 +370,7 @@ Key endpoints documented in the OpenAPI spec: - Records: `/records/query`, `/records/modify`, `/records/lookup`, `/records/changes` - Zones: `/zones/list`, `/zones/lookup`, `/zones/modify`, `/zones/changes` - Subscriptions: `/subscriptions/list`, `/subscriptions/lookup`, `/subscriptions/modify` -- Users: `/users/current`, `/users/discover`, `/users/lookup/contacts` +- Users: `/users/caller`, `/users/discover` (POST + GET), `/users/lookup/email`, `/users/lookup/id` - Assets: `/assets/upload` - Tokens: `/tokens/create`, `/tokens/register` @@ -386,7 +423,7 @@ See `.claude/docs/README.md` for detailed topic breakdowns and integration guida For detailed schema workflows and integration: -- **AI Schema Workflow** (`Examples/Celestra/AI_SCHEMA_WORKFLOW.md`) - Comprehensive guide for understanding, designing, modifying, and validating CloudKit schemas with text-based tools +- **AI Schema Workflow** (`Examples/CelestraCloud/.claude/AI_SCHEMA_WORKFLOW.md`) - Comprehensive guide for understanding, designing, modifying, and validating CloudKit schemas with text-based tools - **Quick Reference** (`Examples/SCHEMA_QUICK_REFERENCE.md`) - One-page cheat sheet with syntax, patterns, cktool commands, and troubleshooting ## Additional Notes diff --git a/Examples/BushelCloud/.claude/s2s-auth-details.md b/Examples/BushelCloud/.claude/s2s-auth-details.md index 83fcd889..ae3503f9 100644 --- a/Examples/BushelCloud/.claude/s2s-auth-details.md +++ b/Examples/BushelCloud/.claude/s2s-auth-details.md @@ -49,12 +49,12 @@ struct BushelCloudKitService { ) // 4. Initialize CloudKit service - self.service = try CloudKitService( + self.service = CloudKitService( containerIdentifier: containerIdentifier, tokenManager: tokenManager, - environment: .development, // or .production - database: .public + environment: .development // or .production ) + // Pass database: .public(.prefers(.serverToServer)) on each per-call operation. } } ``` @@ -293,10 +293,10 @@ try await uploadXcodeVersions() // References SwiftVersion and RestoreImage **Creating a reference:** ```swift fields["minimumMacOS"] = .reference( - FieldValue.Reference(recordName: "RestoreImage-23C71") + Reference(recordName: "RestoreImage-23C71") ) fields["swiftVersion"] = .reference( - FieldValue.Reference(recordName: "SwiftVersion-6.0") + Reference(recordName: "SwiftVersion-6.0") ) ``` @@ -371,10 +371,11 @@ let environment: CloudKitEnvironment = { #endif }() -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: containerID, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) +// Each operation picks its database scope explicitly, e.g. +// `database: .public(.prefers(.serverToServer))`. ``` diff --git a/Examples/BushelCloud/.github/actions/setup-mistkit/action.yml b/Examples/BushelCloud/.github/actions/setup-mistkit/action.yml deleted file mode 100644 index 70a23028..00000000 --- a/Examples/BushelCloud/.github/actions/setup-mistkit/action.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Setup MistKit -description: Replaces the local MistKit path dependency with a remote branch reference - -inputs: - branch: - description: MistKit branch to use (leave empty to keep the local path dependency) - -runs: - using: composite - steps: - - name: Update Package.swift (Unix) - if: inputs.branch != '' && runner.os != 'Windows' - shell: bash - run: | - if [ "$RUNNER_OS" = "macOS" ]; then - sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift - else - sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift - fi - rm -f Package.resolved - - name: Update Package.swift (Windows) - if: inputs.branch != '' && runner.os == 'Windows' - shell: pwsh - run: | - (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", branch: `"${{ inputs.branch }}`")" | Set-Content Package.swift - Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue diff --git a/Examples/BushelCloud/.github/workflows/BushelCloud.yml b/Examples/BushelCloud/.github/workflows/BushelCloud.yml index 222888f2..f83f5a53 100644 --- a/Examples/BushelCloud/.github/workflows/BushelCloud.yml +++ b/Examples/BushelCloud/.github/workflows/BushelCloud.yml @@ -1,50 +1,125 @@ name: BushelCloud on: push: - branches-ignore: - - '*WIP' + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + env: PACKAGE_NAME: BushelCloud + MISTKIT_BRANCH: v1.0.0-beta.1 + jobs: + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + steps: + - id: check + name: Determine matrix scope + run: | + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + run: | + # BushelCloud's Package.swift declares swift-tools-version: 6.2, + # so Swift 6.1 is not supported. + if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=["6.2","6.3"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=["6.3"]' >> "$GITHUB_OUTPUT" + fi + build-ubuntu: name: Build on Ubuntu + needs: configure runs-on: ubuntu-latest - container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + container: swift:${{ matrix.swift }}-${{ matrix.os }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: + fail-fast: false matrix: - os: [noble, jammy] - swift: - - version: "6.2" - - version: "6.3" - nightly: true + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup MistKit - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} - - uses: brightdigit/swift-build@v1.4.2 + - uses: brightdigit/swift-build@v1 + id: build with: skip-package-resolved: true - - uses: sersoft-gmbh/swift-coverage-action@v4 + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + fi + - uses: sersoft-gmbh/swift-coverage-action@v5 + if: steps.build.outputs.contains-code-coverage == 'true' id: coverage-files with: fail-on-empty-output: true minimum-coverage: 70 - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true - flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && '-nightly' || '' }} + flags: swift-${{ matrix.swift }}-${{ matrix.os }} verbose: true token: ${{ secrets.CODECOV_TOKEN }} files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + # build-windows: # name: Build on Windows + # needs: configure # runs-on: ${{ matrix.runs-on }} - # if: "!contains(github.event.head_commit.message, 'ci skip')" + # if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} # strategy: # fail-fast: false # matrix: @@ -52,14 +127,23 @@ jobs: # swift: # - version: swift-6.2-release # build: 6.2-RELEASE + # - version: swift-6.3-release + # build: 6.3-RELEASE # steps: - # - uses: actions/checkout@v4 - # - uses: brightdigit/swift-build@v1.4.2 + # - uses: actions/checkout@v6 + # - name: Setup MistKit + # uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + # with: + # branch: ${{ env.MISTKIT_BRANCH }} + # - uses: brightdigit/swift-build@v1 + # id: build # with: # windows-swift-version: ${{ matrix.swift.version }} # windows-swift-build: ${{ matrix.swift.build }} + # skip-package-resolved: true # - name: Upload coverage to Codecov - # uses: codecov/codecov-action@v5 + # if: steps.build.outputs.contains-code-coverage == 'true' + # uses: codecov/codecov-action@v6 # with: # fail_ci_if_error: true # flags: swift-${{ matrix.swift.version }},windows @@ -67,65 +151,101 @@ jobs: # token: ${{ secrets.CODECOV_TOKEN }} # os: windows # swift_project: BushelCloud-Package + + # Minimal macOS builds — always runs (SPM + iOS) build-macos: name: Build on macOS - env: - PACKAGE_NAME: BushelCloud - runs-on: ${{ matrix.runs-on }} - if: "!contains(github.event.head_commit.message, 'ci skip')" + runs-on: macos-26 + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: include: - # SPM Build Matrix - - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + # SPM build + - xcode: "/Applications/Xcode_26.4.app" - # macOS Build Matrix - - type: macos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" - - # iOS Build Matrix + # iOS build - type: ios - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "iPhone 17 Pro" - osVersion: "26.0.1" + osVersion: "26.4.1" download-platform: true - - # watchOS Build Matrix + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + minimum-coverage: 70 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + # Full macOS platform builds — only on main, semver branches, and PRs targeting them + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + runs-on: macos-26 + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # macOS + - type: macos + xcode: "/Applications/Xcode_26.4.app" + + # watchOS - type: watchos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.0" + osVersion: "26.4" download-platform: true - # tvOS Build Matrix + # tvOS - type: tvos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple TV" - osVersion: "26.0" + osVersion: "26.4" download-platform: true - # visionOS Build Matrix + # visionOS - type: visionos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Vision Pro" - osVersion: "26.0" + osVersion: "26.4.1" download-platform: true - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup MistKit - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} - name: Build and Test - uses: brightdigit/swift-build@v1.4.2 + id: build + uses: brightdigit/swift-build@v1 with: scheme: ${{ env.PACKAGE_NAME }}-Package type: ${{ matrix.type }} @@ -134,47 +254,28 @@ jobs: osVersion: ${{ matrix.osVersion }} download-platform: ${{ matrix.download-platform }} skip-package-resolved: true - - # Coverage Steps - name: Process Coverage - uses: sersoft-gmbh/swift-coverage-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 with: minimum-coverage: 70 - - name: Upload Coverage - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} lint: name: Linting - if: "!contains(github.event.head_commit.message, 'ci skip')" runs-on: ubuntu-latest - needs: [build-ubuntu, build-macos] # , build-windows] - env: - MINT_PATH: .mint/lib - MINT_LINK_PATH: .mint/bin + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-macos-platforms] steps: - - uses: actions/checkout@v4 - - name: Cache mint - id: cache-mint - uses: actions/cache@v4 - env: - cache-name: cache + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 with: - path: | - .mint - Mint - key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} - restore-keys: | - ${{ runner.os }}-mint- - - name: Install mint - if: steps.cache-mint.outputs.cache-hit == '' - run: | - git clone https://github.com/yonaskolb/Mint.git - cd Mint - swift run mint install yonaskolb/mint + cache: true - name: Lint run: | set -e diff --git a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml index 0b55b893..8a0a13d5 100644 --- a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml +++ b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml @@ -37,6 +37,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: v1.0.0-beta.1 + - name: Verify Swift version run: | swift --version diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index a2480c88..f548dc07 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = 00e771997b907cafc7482ad1246d678f92cc365f - parent = eba906549ea28df0aa196f48d74538fcdc11aa3f + commit = 5bb449083cf63d4752dea48fe5579efc16ba7374 + parent = eee0670d6beea1ff11341d780e084bee64c3a799 method = merge cmdver = 0.4.9 diff --git a/Examples/BushelCloud/.swift-format b/Examples/BushelCloud/.swift-format index d5fd1870..5c31a3e1 100644 --- a/Examples/BushelCloud/.swift-format +++ b/Examples/BushelCloud/.swift-format @@ -29,7 +29,7 @@ "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, - "FileScopedDeclarationPrivacy" : true, + "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, diff --git a/Examples/BushelCloud/.swiftlint.yml b/Examples/BushelCloud/.swiftlint.yml index 49a788ef..c6791ed9 100644 --- a/Examples/BushelCloud/.swiftlint.yml +++ b/Examples/BushelCloud/.swiftlint.yml @@ -53,6 +53,7 @@ opt_in_rules: - nslocalizedstring_require_bundle - number_separator - object_literal + - one_declaration_per_file - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call @@ -107,6 +108,11 @@ line_length: closure_body_length: - 50 - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 identifier_name: excluded: - id @@ -131,4 +137,10 @@ disabled_rules: - trailing_comma - opening_brace - optional_data_string_conversion - - pattern_matching_keywords \ No newline at end of file + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error \ No newline at end of file diff --git a/Examples/BushelCloud/CLAUDE.md b/Examples/BushelCloud/CLAUDE.md index fc8d0603..5c10c079 100644 --- a/Examples/BushelCloud/CLAUDE.md +++ b/Examples/BushelCloud/CLAUDE.md @@ -420,7 +420,7 @@ CloudKit references use record names (not IDs): ```swift // Creating a reference fields["minimumMacOS"] = .reference( - FieldValue.Reference(recordName: "RestoreImage-23C71") + Reference(recordName: "RestoreImage-23C71") ) // Reading a reference @@ -439,7 +439,10 @@ CloudKit enforces a **200 operations per request** limit. Operations are automat let batchSize = 200 let batches = operations.chunked(into: batchSize) for batch in batches { - let results = try await service.modifyRecords(batch) + let results = try await service.modifyRecords( + batch, + database: .public(.prefers(.serverToServer)) + ) } ``` @@ -482,14 +485,15 @@ let tokenManager = try ServerToServerAuthManager( pemString: pemFileContents ) -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: "iCloud.com.company.App", tokenManager: tokenManager, - environment: .development, - database: .public + environment: .development ) ``` +The database scope is picked per call now (e.g. `database: .public(.prefers(.serverToServer))`); it's no longer fixed at init time. + **Key setup**: 1. Generate key pair in CloudKit Dashboard 2. Download .pem file to `~/.cloudkit/bushel-private-key.pem` diff --git a/Examples/BushelCloud/Mintfile b/Examples/BushelCloud/Mintfile deleted file mode 100644 index 7c931c11..00000000 --- a/Examples/BushelCloud/Mintfile +++ /dev/null @@ -1,3 +0,0 @@ -swiftlang/swift-format@602.0.0 -realm/SwiftLint@0.62.2 -peripheryapp/periphery@3.2.0 diff --git a/Examples/BushelCloud/Package.resolved b/Examples/BushelCloud/Package.resolved index 49126264..e0b877de 100644 --- a/Examples/BushelCloud/Package.resolved +++ b/Examples/BushelCloud/Package.resolved @@ -37,15 +37,6 @@ "version" : "1.0.2" } }, - { - "identity" : "lrucache", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nicklockwood/LRUCache.git", - "state" : { - "revision" : "0d91406ecd4d6c1c56275866f00508d9aeacc92a", - "version" : "1.2.0" - } - }, { "identity" : "osver", "kind" : "remoteSourceControl", @@ -69,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" } }, { @@ -78,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" } }, { @@ -87,17 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" } }, { @@ -105,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { @@ -114,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-configuration.git", "state" : { - "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", - "version" : "1.0.0" + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { @@ -123,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" } }, { @@ -141,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -150,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", - "version" : "1.9.0" + "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", + "version" : "1.11.0" } }, { @@ -159,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" + "revision" : "576a65b4ffb8c12ddad4950dc21eea2ef071bec2", + "version" : "1.3.0" } }, { @@ -168,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle", "state" : { - "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", - "version" : "2.9.1" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { @@ -177,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { @@ -186,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/scinfu/SwiftSoup.git", "state" : { - "revision" : "d86f244ed497d48012782e2f59c985a55e77b3f5", - "version" : "2.11.3" + "revision" : "6c7915e16f729857aec3e99068c361e58a00ed68", + "version" : "2.13.4" } } ], diff --git a/Examples/BushelCloud/Scripts/bootstrap.sh b/Examples/BushelCloud/Scripts/bootstrap.sh index 3b0a5da7..e1a722b9 100755 --- a/Examples/BushelCloud/Scripts/bootstrap.sh +++ b/Examples/BushelCloud/Scripts/bootstrap.sh @@ -36,53 +36,50 @@ fi echo "" -# Check if Mint is installed -echo "Checking for Mint (Swift package manager for executables)..." -if ! command -v mint &> /dev/null; then - echo -e "${YELLOW}Mint is not installed.${NC}" +# Check if mise is installed +echo "Checking for mise (polyglot tool version manager)..." +if ! command -v mise &> /dev/null; then + echo -e "${YELLOW}mise is not installed.${NC}" echo "" - read -p "Would you like to install Mint? (y/n) " -n 1 -r + read -p "Would you like to install mise? (y/n) " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "Installing Mint..." + echo "Installing mise..." if command -v brew &> /dev/null; then - brew install mint - echo -e "${GREEN}✓${NC} Mint installed via Homebrew" + brew install mise + echo -e "${GREEN}✓${NC} mise installed via Homebrew" else - echo -e "${YELLOW}Homebrew not found. Installing Mint from source...${NC}" - git clone https://github.com/yonaskolb/Mint.git /tmp/Mint - cd /tmp/Mint - swift run mint install yonaskolb/mint - cd - - rm -rf /tmp/Mint - echo -e "${GREEN}✓${NC} Mint installed from source" + echo -e "${YELLOW}Homebrew not found. Installing mise via official installer...${NC}" + curl https://mise.run | sh + echo -e "${GREEN}✓${NC} mise installed" + echo -e "${YELLOW}Add ~/.local/bin to your PATH and run \`mise activate\` per the mise docs.${NC}" fi else - echo -e "${YELLOW}Skipping Mint installation. Some tools may not be available.${NC}" + echo -e "${YELLOW}Skipping mise installation. Some tools may not be available.${NC}" fi else - echo -e "${GREEN}✓${NC} Mint is installed" + echo -e "${GREEN}✓${NC} mise is installed" fi echo "" -# Install development tools via Mint -if command -v mint &> /dev/null && [ -f "Mintfile" ]; then - echo "Installing development tools from Mintfile..." +# Install development tools via mise +if command -v mise &> /dev/null && [ -f "mise.toml" ]; then + echo "Installing development tools from mise.toml..." echo "This may take a few minutes on first run..." echo "" - if mint bootstrap; then + if mise install; then echo -e "${GREEN}✓${NC} Development tools installed" echo " - SwiftLint (code linting)" echo " - swift-format (code formatting)" echo " - periphery (unused code detection)" else echo -e "${YELLOW}WARNING: Failed to install some development tools.${NC}" - echo "You can install them manually later with: mint bootstrap" + echo "You can install them manually later with: mise install" fi else - echo -e "${YELLOW}Skipping development tools installation (Mint not available or Mintfile not found)${NC}" + echo -e "${YELLOW}Skipping development tools installation (mise not available or mise.toml not found)${NC}" fi echo "" diff --git a/Examples/BushelCloud/Scripts/lint.sh b/Examples/BushelCloud/Scripts/lint.sh index 832749f1..f1907ff7 100755 --- a/Examples/BushelCloud/Scripts/lint.sh +++ b/Examples/BushelCloud/Scripts/lint.sh @@ -6,11 +6,11 @@ ERRORS=0 run_command() { - if [ "$LINT_MODE" = "STRICT" ]; then - "$@" || ERRORS=$((ERRORS + 1)) - else - "$@" - fi + if [ "$LINT_MODE" = "STRICT" ]; then + "$@" || ERRORS=$((ERRORS + 1)) + else + "$@" + fi } if [ "$LINT_MODE" = "INSTALL" ]; then @@ -27,54 +27,43 @@ else PACKAGE_DIR="${SRCROOT}" fi -# Detect OS and set paths accordingly -if [ "$(uname)" = "Darwin" ]; then - DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" -elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then - DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint" -elif [ "$(uname)" = "Linux" ]; then - DEFAULT_MINT_PATH="/usr/local/bin/mint" -else - echo "Unsupported operating system" - exit 1 +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi -# Use environment MINT_CMD if set, otherwise use default path -MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} - -export MINT_PATH="$PACKAGE_DIR/.mint" -MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" -MINT_RUN="$MINT_CMD run $MINT_ARGS" - if [ "$LINT_MODE" = "NONE" ]; then exit elif [ "$LINT_MODE" = "STRICT" ]; then SWIFTFORMAT_OPTIONS="--configuration .swift-format" SWIFTLINT_OPTIONS="--strict" + STRINGSLINT_OPTIONS="--config .strict.stringslint.yml" else SWIFTFORMAT_OPTIONS="--configuration .swift-format" SWIFTLINT_OPTIONS="" + STRINGSLINT_OPTIONS="--config .stringslint.yml" fi pushd $PACKAGE_DIR -run_command $MINT_CMD bootstrap -m Mintfile if [ -z "$CI" ]; then - run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests - run_command $MINT_RUN swiftlint --fix + run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command swiftlint --fix fi if [ -z "$FORMAT_ONLY" ]; then - run_command $MINT_RUN swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests - run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command swiftlint lint $SWIFTLINT_OPTIONS # Check for compilation errors run_command swift build --build-tests fi -$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "BushelCloud" +$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "BushelCloud" + +# Generated files now automatically include ignore directives via OpenAPI generator configuration if [ -z "$CI" ]; then - run_command $MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check + run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check fi popd diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift index 2e2dab7e..5313a8bc 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift @@ -134,9 +134,12 @@ internal enum SyncCommand { printTypeResult("XcodeVersions", result.xcodeVersions) printTypeResult("SwiftVersions", result.swiftVersions) - let totalCreated = result.restoreImages.created + result.xcodeVersions.created + result.swiftVersions.created - let totalUpdated = result.restoreImages.updated + result.xcodeVersions.updated + result.swiftVersions.updated - let totalFailed = result.restoreImages.failed + result.xcodeVersions.failed + result.swiftVersions.failed + let totalCreated = + result.restoreImages.created + result.xcodeVersions.created + result.swiftVersions.created + let totalUpdated = + result.restoreImages.updated + result.xcodeVersions.updated + result.swiftVersions.updated + let totalFailed = + result.restoreImages.failed + result.xcodeVersions.failed + result.swiftVersions.failed print(String(repeating: "-", count: 60)) print("TOTAL:") diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md index b66632b7..27bcc79c 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md @@ -16,14 +16,15 @@ let tokenManager = try ServerToServerAuthManager( pemString: pemFileContents ) -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: "iCloud.com.company.App", tokenManager: tokenManager, - environment: .development, - database: .public + environment: .development ) ``` +The database scope is now chosen per call (see "Batch Operations" below). + Authentication tokens are automatically refreshed by MistKit. ## Batch Operations @@ -33,7 +34,10 @@ CloudKit limits operations to 200 per request. BushelCloud handles this automati ```swift let batches = operations.chunked(into: 200) for batch in batches { - let results = try await service.modifyRecords(batch) + let results = try await service.modifyRecords( + batch, + database: .public(.prefers(.serverToServer)) + ) // Handle results... } ``` @@ -99,7 +103,7 @@ Create relationships using record names: ```swift fields["minimumMacOS"] = .reference( - FieldValue.Reference(recordName: "RestoreImage-23C71") + Reference(recordName: "RestoreImage-23C71") ) ``` @@ -116,7 +120,10 @@ if case .reference(let ref) = fieldValue { Check for partial failures in batch operations: ```swift -let results = try await service.modifyRecords(operations) +let results = try await service.modifyRecords( + operations, + database: .public(.prefers(.serverToServer)) +) for result in results { if result.isError { logger.error("Failed: \\(result.serverErrorCode ?? "unknown")") diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift index 3546abdf..a07f10b7 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift @@ -35,6 +35,7 @@ public enum BushelCloudKitError: LocalizedError { case privateKeyFileReadFailed(path: String, error: any Error) case invalidPEMFormat(reason: String, suggestion: String) case invalidMetadataRecord(recordName: String) + case invalidKeyID(reason: String, suggestion: String) public var errorDescription: String? { switch self { @@ -55,6 +56,12 @@ public enum BushelCloudKitError: LocalizedError { """ case .invalidMetadataRecord(let recordName): return "Invalid DataSourceMetadata record: \(recordName) (missing required fields)" + case .invalidKeyID(let reason, let suggestion): + return """ + Invalid CloudKit Server-to-Server Key ID: \(reason) + + Suggestion: \(suggestion) + """ } } @@ -62,6 +69,8 @@ public enum BushelCloudKitError: LocalizedError { switch self { case .invalidPEMFormat(_, let suggestion): return suggestion + case .invalidKeyID(_, let suggestion): + return suggestion case .privateKeyFileNotFound(let path): return """ Ensure the file exists at \(path) or set CLOUDKIT_PRIVATE_KEY_PATH environment variable. diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 48be625f..9acbe26e 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -82,6 +82,9 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol privateKeyPath: String, environment: Environment = .development ) throws { + // Validate Key ID format before any file IO + try KeyIDValidator.validate(keyID) + // Read PEM file from disk guard FileManager.default.fileExists(atPath: privateKeyPath) else { throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) @@ -103,11 +106,10 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol pemString: pemString ) - self.service = try CloudKitService( + self.service = CloudKitService( containerIdentifier: containerIdentifier, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) } @@ -128,6 +130,9 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol pemString: String, environment: Environment = .development ) throws { + // Validate Key ID format before any cryptographic work + try KeyIDValidator.validate(keyID) + // Validate PEM format BEFORE passing to MistKit // This provides better error messages than MistKit's internal validation try PEMValidator.validate(pemString) @@ -138,19 +143,18 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol pemString: pemString ) - self.service = try CloudKitService( + self.service = CloudKitService( containerIdentifier: containerIdentifier, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) } // MARK: - RecordManaging Protocol Requirements - /// Query all records of a given type + /// Query all records of a given type, automatically paginating public func queryRecords(recordType: String) async throws -> [RecordInfo] { - try await service.queryRecords(recordType: recordType, limit: 200) + try await service.queryAllRecords(recordType: recordType) } /// Fetch existing record names for create/update classification @@ -163,7 +167,11 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol public func fetchExistingRecordNames(recordType: String) async throws -> Set { Self.logger.debug("Pre-fetching existing record names for \(recordType)") - let records = try await queryRecords(recordType: recordType) + let records = try await service.queryAllRecords( + recordType: recordType, + desiredKeys: [], + database: .public(.prefers(.serverToServer)) + ) let recordNames = Set(records.map(\.recordName)) Self.logger.debug("Found \(recordNames.count) existing \(recordType) records") @@ -174,11 +182,8 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol /// /// This is the protocol-conforming version that doesn't track create vs update. /// For detailed tracking, use the overload with `classification` parameter. - public func executeBatchOperations( - _ operations: [RecordOperation], - recordType: String - ) async throws { - // Create empty classification (no tracking) + public func executeBatchOperations(_ operations: [RecordOperation]) async throws { + guard let recordType = operations.first?.recordType else { return } let classification = OperationClassification(proposedRecords: [], existingRecords: []) _ = try await executeBatchOperations( operations, recordType: recordType, classification: classification @@ -201,9 +206,12 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol classification: OperationClassification ) async throws -> SyncEngine.TypeSyncResult { let batchSize = 200 - let batches = operations.chunked(into: batchSize) + let batches = stride(from: 0, to: operations.count, by: batchSize).map { + Array(operations[$0..(bushelPrefixed: "sync.force") internal static let minInterval = OptionalConfigKey(bushelPrefixed: "sync.min_interval") internal static let source = OptionalConfigKey(bushelPrefixed: "sync.source") - internal static let jsonOutputFile = OptionalConfigKey(bushelPrefixed: "sync.json_output_file") + internal static let jsonOutputFile = OptionalConfigKey( + bushelPrefixed: "sync.json_output_file") } // MARK: - Export Command Configuration diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift index a024c056..0fe147d1 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift @@ -28,8 +28,8 @@ // public import BushelFoundation -public import BushelUtilities -public import Foundation +internal import BushelUtilities +internal import Foundation public import MistKit // MARK: - CloudKitRecord Conformance diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift index 66f7ad57..8b76bb81 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift @@ -28,8 +28,8 @@ // public import BushelFoundation -public import BushelUtilities -public import Foundation +internal import BushelUtilities +internal import Foundation public import MistKit // MARK: - CloudKitRecord Conformance @@ -92,7 +92,7 @@ extension XcodeVersionRecord: @retroactive CloudKitRecord { if let minimumMacOS { fields["minimumMacOS"] = .reference( - FieldValue.Reference( + Reference( recordName: minimumMacOS, action: nil ) @@ -101,7 +101,7 @@ extension XcodeVersionRecord: @retroactive CloudKitRecord { if let includedSwiftVersion { fields["includedSwiftVersion"] = .reference( - FieldValue.Reference( + Reference( recordName: includedSwiftVersion, action: nil ) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift index 413caff6..bad03b68 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift @@ -28,6 +28,7 @@ // import Foundation +import Synchronization /// Console output control for CLI interface /// @@ -36,11 +37,18 @@ import Foundation /// /// **Important**: All output goes to stderr to keep stdout clean for structured output (JSON, etc.) public enum ConsoleOutput { - /// Global verbose mode flag + private static let _isVerbose = Mutex(false) + + /// Global verbose mode flag. /// - /// Note: This is marked with `nonisolated(unsafe)` because it's set once at startup - /// before any concurrent access and then only read. This pattern is safe for CLI tools. - nonisolated(unsafe) public static var isVerbose = false + /// Backed by a `Mutex` so reads and writes are concurrency-safe across + /// arbitrary actors and threads — the previous `nonisolated(unsafe)` was a + /// data race waiting to happen if any caller ever toggled this off the main + /// path. + public static var isVerbose: Bool { + get { _isVerbose.withLock { $0 } } + set { _isVerbose.withLock { $0 = newValue } } + } /// Print to stderr (keeping stdout clean for structured output) /// diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift index e00bbff9..94f3138d 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift @@ -59,7 +59,7 @@ internal struct MockCloudKitServiceTests { fields: record.toCloudKitFields() ) - try await service.executeBatchOperations([operation], recordType: "RestoreImage") + try await service.executeBatchOperations([operation]) let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") #expect(storedRecords.count == 1) @@ -79,7 +79,7 @@ internal struct MockCloudKitServiceTests { recordName: recordName, fields: initialRecord.toCloudKitFields() ) - try await service.executeBatchOperations([createOp], recordType: "RestoreImage") + try await service.executeBatchOperations([createOp]) // Replace with updated record let updatedRecord = RestoreImageRecord( @@ -103,7 +103,7 @@ internal struct MockCloudKitServiceTests { recordName: recordName, fields: updatedRecord.toCloudKitFields() ) - try await service.executeBatchOperations([replaceOp], recordType: "RestoreImage") + try await service.executeBatchOperations([replaceOp]) // Verify only one record exists with updated data let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") @@ -130,7 +130,7 @@ internal struct MockCloudKitServiceTests { recordName: recordName, fields: record.toCloudKitFields() ) - try await service.executeBatchOperations([createOp], recordType: "RestoreImage") + try await service.executeBatchOperations([createOp]) // Delete record let deleteOp = RecordOperation( @@ -138,7 +138,7 @@ internal struct MockCloudKitServiceTests { recordType: "RestoreImage", recordName: recordName ) - try await service.executeBatchOperations([deleteOp], recordType: "RestoreImage") + try await service.executeBatchOperations([deleteOp]) // Verify record is gone let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") @@ -170,11 +170,8 @@ internal struct MockCloudKitServiceTests { ), ] - try await service.executeBatchOperations( - Array(operations[0...1]), - recordType: "RestoreImage" - ) - try await service.executeBatchOperations([operations[2]], recordType: "XcodeVersion") + try await service.executeBatchOperations(Array(operations[0...1])) + try await service.executeBatchOperations([operations[2]]) let restoreImages = await service.getStoredRecords(ofType: "RestoreImage") let xcodeVersions = await service.getStoredRecords(ofType: "XcodeVersion") @@ -213,7 +210,7 @@ internal struct MockCloudKitServiceTests { ) do { - try await service.executeBatchOperations([operation], recordType: "RestoreImage") + try await service.executeBatchOperations([operation]) Issue.record("Expected error to be thrown") } catch is MockCloudKitError { // Success - error was thrown as expected @@ -244,8 +241,8 @@ internal struct MockCloudKitServiceTests { ) ] - try await service.executeBatchOperations(batch1, recordType: "RestoreImage") - try await service.executeBatchOperations(batch2, recordType: "XcodeVersion") + try await service.executeBatchOperations(batch1) + try await service.executeBatchOperations(batch2) let history = await service.getOperationHistory() #expect(history.count == 2) @@ -264,7 +261,7 @@ internal struct MockCloudKitServiceTests { recordName: "test", fields: TestFixtures.sonoma1421.toCloudKitFields() ) - try await service.executeBatchOperations([operation], recordType: "RestoreImage") + try await service.executeBatchOperations([operation]) // Clear storage await service.clearStorage() diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift index 92c88015..968e0732 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift @@ -51,7 +51,7 @@ internal struct CloudKitErrorHandlingTests { ) do { - try await service.executeBatchOperations([operation], recordType: "RestoreImage") + try await service.executeBatchOperations([operation]) Issue.record("Expected quota exceeded error to be thrown") } catch let error as MockCloudKitError { if case .quotaExceeded = error { @@ -78,7 +78,7 @@ internal struct CloudKitErrorHandlingTests { ) do { - try await service.executeBatchOperations([operation], recordType: "XcodeVersion") + try await service.executeBatchOperations([operation]) Issue.record("Expected reference validation error to be thrown") } catch let error as MockCloudKitError { if case .validatingReferenceError = error { @@ -105,7 +105,7 @@ internal struct CloudKitErrorHandlingTests { ) do { - try await service.executeBatchOperations([operation], recordType: "RestoreImage") + try await service.executeBatchOperations([operation]) Issue.record("Expected conflict error to be thrown") } catch let error as MockCloudKitError { if case .conflict = error { diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift index b633748c..7329f425 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift @@ -82,18 +82,16 @@ internal actor MockCloudKitService: RecordManaging { return storedRecords[recordType] ?? [] } - internal func executeBatchOperations( - _ operations: [RecordOperation], - recordType: String - ) async throws { + internal func executeBatchOperations(_ operations: [RecordOperation]) async throws { operationHistory.append(operations) if shouldFailModify { throw modifyError ?? MockCloudKitError.networkError } - // Process operations + // Each operation carries its own record type for operation in operations { + let recordType = operation.recordType switch operation.operationType { case .create, .forceReplace: handleCreateOrReplace(operation, recordType: recordType) diff --git a/Examples/BushelCloud/mise.toml b/Examples/BushelCloud/mise.toml new file mode 100644 index 00000000..6df20abb --- /dev/null +++ b/Examples/BushelCloud/mise.toml @@ -0,0 +1,7 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4" diff --git a/Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md b/Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md index 3d564452..a3a22b04 100644 --- a/Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md +++ b/Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md @@ -367,12 +367,12 @@ let tokenManager = try ServerToServerAuthManager( pemString: pemString ) -// Create CloudKit service -let service = try CloudKitService( +// Create CloudKit service — database is now selected per call, +// e.g. `database: .public(.prefers(.serverToServer))`. +let service = CloudKitService( containerIdentifier: containerID, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) ``` @@ -756,7 +756,7 @@ Switch from string-based to proper CloudKit references: fields["feedRecordName"] = .string(feedRecordName) // Use: -fields["feed"] = .reference(FieldValue.Reference(recordName: feedRecordName)) +fields["feed"] = .reference(Reference(recordName: feedRecordName)) ``` **Trade-off Analysis**: diff --git a/Examples/CelestraCloud/.github/actions/setup-mistkit/action.yml b/Examples/CelestraCloud/.github/actions/setup-mistkit/action.yml deleted file mode 100644 index 70a23028..00000000 --- a/Examples/CelestraCloud/.github/actions/setup-mistkit/action.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Setup MistKit -description: Replaces the local MistKit path dependency with a remote branch reference - -inputs: - branch: - description: MistKit branch to use (leave empty to keep the local path dependency) - -runs: - using: composite - steps: - - name: Update Package.swift (Unix) - if: inputs.branch != '' && runner.os != 'Windows' - shell: bash - run: | - if [ "$RUNNER_OS" = "macOS" ]; then - sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift - else - sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift - fi - rm -f Package.resolved - - name: Update Package.swift (Windows) - if: inputs.branch != '' && runner.os == 'Windows' - shell: pwsh - run: | - (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", branch: `"${{ inputs.branch }}`")" | Set-Content Package.swift - Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue diff --git a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml index 08f5b1f1..023e20de 100644 --- a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml +++ b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml @@ -1,133 +1,247 @@ name: CelestraCloud on: push: - branches-ignore: - - '*WIP' + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + env: PACKAGE_NAME: CelestraCloud + MISTKIT_BRANCH: v1.0.0-beta.1 + jobs: + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + steps: + - id: check + name: Determine matrix scope + run: | + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + run: | + # CelestraCloud's Package.swift declares swift-tools-version: 6.2, + # so Swift 6.1 is not supported. + if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=["6.2","6.3"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=["6.3"]' >> "$GITHUB_OUTPUT" + fi + build-ubuntu: name: Build on Ubuntu + needs: configure runs-on: ubuntu-latest - container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + container: swift:${{ matrix.swift }}-${{ matrix.os }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: + fail-fast: false matrix: - os: [noble, jammy] - swift: - - version: "6.2" # Uses Swift 6.2.3 release - works fine - - version: "6.3" - nightly: true + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup MistKit - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} - - uses: brightdigit/swift-build@v1.4.2 + - uses: brightdigit/swift-build@v1 + id: build with: skip-package-resolved: true - - uses: sersoft-gmbh/swift-coverage-action@v4 + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + fi + - uses: sersoft-gmbh/swift-coverage-action@v5 + if: steps.build.outputs.contains-code-coverage == 'true' id: coverage-files - with: + with: fail-on-empty-output: true - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true - flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && 'nightly' || '' }} - verbose: true - token: ${{ secrets.CODECOV_TOKEN }} - files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + flags: swift-${{ matrix.swift }}-${{ matrix.os }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-windows: name: Build on Windows + needs: configure runs-on: ${{ matrix.runs-on }} - if: "!contains(github.event.head_commit.message, 'ci skip')" + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: + fail-fast: false matrix: runs-on: [windows-2022, windows-2025] swift: - - version: swift-6.3-branch - build: 6.3-DEVELOPMENT-SNAPSHOT-2025-12-21-a + - version: swift-6.2-release + build: 6.2-RELEASE + - version: swift-6.3-release + build: 6.3-RELEASE steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup MistKit - uses: ./.github/actions/setup-mistkit - - uses: brightdigit/swift-build@v1.4.2 + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + - uses: brightdigit/swift-build@v1 + id: build with: windows-swift-version: ${{ matrix.swift.version }} windows-swift-build: ${{ matrix.swift.build }} skip-package-resolved: true - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true flags: swift-${{ matrix.swift.version }},windows - verbose: true + verbose: true token: ${{ secrets.CODECOV_TOKEN }} os: windows + # Minimal macOS builds — always runs (SPM + iOS) build-macos: name: Build on macOS - env: - PACKAGE_NAME: CelestraCloud - runs-on: ${{ matrix.runs-on }} - if: "!contains(github.event.head_commit.message, 'ci skip')" + runs-on: macos-26 + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: include: - # SPM Build Matrix - - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" - - # macOS Build Matrix - - type: macos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + # SPM build + - xcode: "/Applications/Xcode_26.4.app" - # iOS Build + # iOS build - type: ios - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "iPhone 17 Pro" - osVersion: "26.2" + osVersion: "26.4.1" download-platform: true + steps: + - uses: actions/checkout@v6 + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} - # watchOS Build Matrix + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + # Full macOS platform builds — only on main, semver branches, and PRs targeting them + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + runs-on: macos-26 + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # macOS + - type: macos + xcode: "/Applications/Xcode_26.4.app" + + # watchOS - type: watchos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.2" + osVersion: "26.4" download-platform: true - # tvOS Build + # tvOS - type: tvos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple TV" - osVersion: "26.2" + osVersion: "26.4" download-platform: true - # visionOS Build + # visionOS - type: visionos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.2.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Vision Pro" - osVersion: "26.2" + osVersion: "26.4.1" download-platform: true - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup MistKit - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} - name: Build and Test - uses: brightdigit/swift-build@v1.4.2 + id: build + uses: brightdigit/swift-build@v1 with: scheme: ${{ env.PACKAGE_NAME }}-Package type: ${{ matrix.type }} @@ -136,45 +250,26 @@ jobs: osVersion: ${{ matrix.osVersion }} download-platform: ${{ matrix.download-platform }} skip-package-resolved: true - - # Common Coverage Steps - name: Process Coverage - uses: sersoft-gmbh/swift-coverage-action@v4 - + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 - name: Upload Coverage - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} lint: name: Linting - if: "!contains(github.event.head_commit.message, 'ci skip')" runs-on: ubuntu-latest - needs: [build-ubuntu, build-windows, build-macos] - env: - MINT_PATH: .mint/lib - MINT_LINK_PATH: .mint/bin + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-macos-platforms, build-windows] steps: - - uses: actions/checkout@v4 - - name: Cache mint - id: cache-mint - uses: actions/cache@v4 - env: - cache-name: cache + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 with: - path: | - .mint - Mint - key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} - restore-keys: | - ${{ runner.os }}-mint- - - name: Install mint - if: steps.cache-mint.outputs.cache-hit == '' - run: | - git clone https://github.com/yonaskolb/Mint.git - cd Mint - swift run mint install yonaskolb/mint + cache: true - name: Lint run: | set -e diff --git a/Examples/CelestraCloud/.github/workflows/update-feeds.yml b/Examples/CelestraCloud/.github/workflows/update-feeds.yml index e0a97e05..100a7179 100644 --- a/Examples/CelestraCloud/.github/workflows/update-feeds.yml +++ b/Examples/CelestraCloud/.github/workflows/update-feeds.yml @@ -49,6 +49,7 @@ env: CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} CLOUDKIT_ENVIRONMENT: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && 'development' || github.event.inputs.environment || 'production' }} CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem + MISTKIT_BRANCH: v1.0.0-beta.1 jobs: # Determine which tier to run based on schedule or manual input @@ -149,7 +150,9 @@ jobs: - name: Setup MistKit if: steps.cache-binary.outputs.cache-hit != 'true' - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} - name: Build CelestraCloud if: steps.cache-binary.outputs.cache-hit != 'true' diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index 1a0e0fd5..5b21d57e 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = b7d7888ceb1837bb0ffc9d6aa92a4b8b12ef163e - parent = e8a3a19f1750806fdb227653e63532cb6e1ce56e + commit = ea897c34cc0cc63c0a4c35bb99bf819535a47c6e + parent = 24c87193074cefc18839009f86983f0df6348bb6 method = merge cmdver = 0.4.9 diff --git a/Examples/CelestraCloud/.swift-format b/Examples/CelestraCloud/.swift-format index d5fd1870..5c31a3e1 100644 --- a/Examples/CelestraCloud/.swift-format +++ b/Examples/CelestraCloud/.swift-format @@ -29,7 +29,7 @@ "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, - "FileScopedDeclarationPrivacy" : true, + "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, diff --git a/Examples/CelestraCloud/.swiftlint.yml b/Examples/CelestraCloud/.swiftlint.yml index 2698b9df..b9797512 100644 --- a/Examples/CelestraCloud/.swiftlint.yml +++ b/Examples/CelestraCloud/.swiftlint.yml @@ -53,6 +53,7 @@ opt_in_rules: - nslocalizedstring_require_bundle - number_separator - object_literal + - one_declaration_per_file - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call @@ -107,6 +108,11 @@ line_length: closure_body_length: - 50 - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 identifier_name: excluded: - id diff --git a/Examples/CelestraCloud/CLAUDE.md b/Examples/CelestraCloud/CLAUDE.md index a445b212..86f87940 100644 --- a/Examples/CelestraCloud/CLAUDE.md +++ b/Examples/CelestraCloud/CLAUDE.md @@ -438,10 +438,11 @@ for article in articles { ```swift let privateKeyPEM = try String(contentsOfFile: privateKeyPath, encoding: .utf8) let tokenManager = try ServerToServerAuthManager(keyID: keyID, pemString: privateKeyPEM) -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: containerID, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) ``` + +Database scope is now selected per call (e.g. `database: .public(.prefers(.serverToServer))`); the init no longer carries a `database:` argument and is not throwing. diff --git a/Examples/CelestraCloud/Mintfile b/Examples/CelestraCloud/Mintfile deleted file mode 100644 index 3586a2be..00000000 --- a/Examples/CelestraCloud/Mintfile +++ /dev/null @@ -1,4 +0,0 @@ -swiftlang/swift-format@602.0.0 -realm/SwiftLint@0.62.2 -peripheryapp/periphery@3.2.0 -apple/swift-openapi-generator@1.10.3 diff --git a/Examples/CelestraCloud/Package.resolved b/Examples/CelestraCloud/Package.resolved index d3e8e3fd..7f43fe4c 100644 --- a/Examples/CelestraCloud/Package.resolved +++ b/Examples/CelestraCloud/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "99359579bf8e74b5ee7b13a4936b0d9e1d09aa0ff2eb5bb043a63f8c00d1fea5", + "originHash" : "5d8dd38c79048a57becd55979d6e7f8592823bb40ba46643077f552d0a951661", "pins" : [ { "identity" : "celestrakit", "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/CelestraKit.git", "state" : { - "revision" : "2549700b90dbc3204eaabb781dc103287694853c", - "version" : "0.0.2" + "branch" : "v0.0.3", + "revision" : "ca9dae2b20c12a4e73b48b2c245ed0cd1dbebcc4" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", - "version" : "1.1.1" + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-configuration.git", "state" : { - "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", - "version" : "1.0.0" + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", - "version" : "1.9.0" + "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", + "version" : "1.11.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" + "revision" : "576a65b4ffb8c12ddad4950dc21eea2ef071bec2", + "version" : "1.3.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle", "state" : { - "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", - "version" : "2.9.1" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/SyndiKit.git", "state" : { - "revision" : "f6f9cc8d1c905e67e66ba2822dd30299ead26867", - "version" : "0.8.0" + "revision" : "bf0315dc6f9a3d72bdf66bb726b86e3ebab6e9ea", + "version" : "0.8.1" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CoreOffice/XMLCoder", "state" : { - "revision" : "5e1ada828d2618ecb79c974e03f79c8f4df90b71", - "version" : "0.18.0" + "revision" : "b2b5d72345bab9e1938a483cf862b498aeed3796", + "version" : "0.18.1" } } ], diff --git a/Examples/CelestraCloud/Package.swift b/Examples/CelestraCloud/Package.swift index f65143e5..fbd81200 100644 --- a/Examples/CelestraCloud/Package.swift +++ b/Examples/CelestraCloud/Package.swift @@ -91,7 +91,7 @@ let package = Package( ], dependencies: [ .package(name: "MistKit", path: "../.."), - .package(url: "https://github.com/brightdigit/CelestraKit.git", from: "0.0.2"), + .package(url: "https://github.com/brightdigit/CelestraKit.git", branch: "v0.0.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package( url: "https://github.com/apple/swift-configuration.git", diff --git a/Examples/CelestraCloud/README.md b/Examples/CelestraCloud/README.md index d67d608a..324ba465 100644 --- a/Examples/CelestraCloud/README.md +++ b/Examples/CelestraCloud/README.md @@ -353,12 +353,14 @@ let tokenManager = try ServerToServerAuthManager( pemString: privateKeyPEM ) -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: containerID, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) + +// Database is selected per call: +// `database: .public(.prefers(.serverToServer))` ``` ## Architecture @@ -582,12 +584,14 @@ let tokenManager = try ServerToServerAuthManager( pemString: privateKeyPEM ) -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: containerID, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) + +// Per-call database selection, e.g.: +// `database: .public(.prefers(.serverToServer))` ``` ### Error Handling Strategy diff --git a/Examples/CelestraCloud/Scripts/header.sh b/Examples/CelestraCloud/Scripts/header.sh index 3b05882e..2242c437 100755 --- a/Examples/CelestraCloud/Scripts/header.sh +++ b/Examples/CelestraCloud/Scripts/header.sh @@ -34,8 +34,9 @@ if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$packa usage fi -# Define the header template -header_template="// +# Define the header template using a heredoc +read -r -d '' header_template <<'EOF' +// // %s // %s // @@ -44,7 +45,7 @@ header_template="// // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -54,7 +55,7 @@ header_template="// // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -62,7 +63,8 @@ header_template="// // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR // OTHER DEALINGS IN THE SOFTWARE. -//" +// +EOF # Loop through each Swift file in the specified directory and subdirectories find "$directory" -type f -name "*.swift" | while read -r file; do @@ -71,7 +73,7 @@ find "$directory" -type f -name "*.swift" | while read -r file; do echo "Skipping $file (generated file)" continue fi - + # Check if the first line is the swift-format-ignore indicator first_line=$(head -n 1 "$file") if [[ "$first_line" == "// swift-format-ignore-file" ]]; then @@ -80,8 +82,14 @@ find "$directory" -type f -name "*.swift" | while read -r file; do fi # Create the header with the current filename - filename=$(basename "$file") - header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + # Escape % characters in user-provided values to prevent format specifier injection + filename=$(basename "$file" | sed 's/%/%%/g') + package_safe=$(printf '%s' "$package" | sed 's/%/%%/g') + creator_safe=$(printf '%s' "$creator" | sed 's/%/%%/g') + year_safe=$(printf '%s' "$year" | sed 's/%/%%/g') + company_safe=$(printf '%s' "$company" | sed 's/%/%%/g') + + header=$(printf "$header_template" "$filename" "$package_safe" "$creator_safe" "$year_safe" "$company_safe") # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" awk ' @@ -96,9 +104,9 @@ find "$directory" -type f -name "*.swift" | while read -r file; do # Add the header to the cleaned file (echo "$header"; echo; cat temp_file) > "$file" - + # Remove the temporary file rm temp_file done -echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." \ No newline at end of file +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Examples/CelestraCloud/Scripts/lint.sh b/Examples/CelestraCloud/Scripts/lint.sh index 0808cbd9..dc840547 100755 --- a/Examples/CelestraCloud/Scripts/lint.sh +++ b/Examples/CelestraCloud/Scripts/lint.sh @@ -24,51 +24,36 @@ if [ -z "$SRCROOT" ]; then SCRIPT_DIR=$(dirname "$(readlink -f "$0")") PACKAGE_DIR="${SCRIPT_DIR}/.." else - PACKAGE_DIR="${SRCROOT}" + PACKAGE_DIR="${SRCROOT}" fi -# Detect OS and set paths accordingly -if [ "$(uname)" = "Darwin" ]; then - DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" -elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then - DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint" -elif [ "$(uname)" = "Linux" ]; then - DEFAULT_MINT_PATH="/usr/local/bin/mint" -else - echo "Unsupported operating system" - exit 1 +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi -# Use environment MINT_CMD if set, otherwise use default path -MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} - -export MINT_PATH="$PACKAGE_DIR/.mint" -MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" -MINT_RUN="$MINT_CMD run $MINT_ARGS" - if [ "$LINT_MODE" = "NONE" ]; then exit elif [ "$LINT_MODE" = "STRICT" ]; then SWIFTFORMAT_OPTIONS="--configuration .swift-format" SWIFTLINT_OPTIONS="--strict" STRINGSLINT_OPTIONS="--config .strict.stringslint.yml" -else +else SWIFTFORMAT_OPTIONS="--configuration .swift-format" SWIFTLINT_OPTIONS="" STRINGSLINT_OPTIONS="--config .stringslint.yml" fi pushd $PACKAGE_DIR -run_command $MINT_CMD bootstrap -m Mintfile if [ -z "$CI" ]; then - run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests - run_command $MINT_RUN swiftlint --fix + run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command swiftlint --fix fi if [ -z "$FORMAT_ONLY" ]; then - run_command $MINT_RUN swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests - run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command swiftlint lint $SWIFTLINT_OPTIONS # Check for compilation errors run_command swift build --build-tests fi @@ -78,7 +63,7 @@ $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "Bright # Generated files now automatically include ignore directives via OpenAPI generator configuration if [ -z "$CI" ]; then - run_command $MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check + run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check fi popd diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift index 2eae8c7f..8d0150ff 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift index 9d6af91b..4680eb7a 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -32,10 +32,6 @@ import CelestraKit import Foundation import MistKit -// MARK: - Supporting Types - -internal struct ExitError: Error {} - // MARK: - Main Type internal enum AddFeedCommand { diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift index 1a8fdc0c..21e75c1f 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ExitError.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ExitError.swift new file mode 100644 index 00000000..9649d521 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ExitError.swift @@ -0,0 +1,31 @@ +// +// ExitError.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Error thrown when the command should exit immediately. +internal struct ExitError: Error {} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift new file mode 100644 index 00000000..fef95ff9 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift @@ -0,0 +1,134 @@ +// +// UpdateCommand+Reporting.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit +import CelestraKit +import Foundation +import MistKit + +extension UpdateCommand { + internal static func createFeedResult( + feed: Feed, + result: FeedUpdateResult, + duration: TimeInterval + ) -> UpdateReport.FeedResult { + switch result { + case .success(let created, let updated): + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: .success, + articlesCreated: created, + articlesUpdated: updated, + duration: duration, + error: nil + ) + case .notModified: + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: .notModified, + articlesCreated: 0, + articlesUpdated: 0, + duration: duration, + error: nil + ) + case .skipped(let reason): + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: .skipped, + articlesCreated: 0, + articlesUpdated: 0, + duration: duration, + error: reason + ) + case .error(let message): + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: .error, + articlesCreated: 0, + articlesUpdated: 0, + duration: duration, + error: message + ) + } + } + + internal static func writeJSONReport( + config: CelestraConfiguration, + summary: UpdateSummary, + feedResults: [UpdateReport.FeedResult], + startTime: Date, + endTime: Date, + path: String + ) throws { + let report = UpdateReport( + startTime: startTime, + endTime: endTime, + configuration: UpdateReport.UpdateConfiguration( + delay: config.update.delay, + skipRobotsCheck: config.update.skipRobotsCheck, + maxFailures: config.update.maxFailures, + minPopularity: config.update.minPopularity, + limit: config.update.limit, + environment: config.cloudkit.environment == .production ? "production" : "development" + ), + summary: UpdateReport.Summary( + totalFeeds: summary.successCount + summary.errorCount + + summary.skippedCount + summary.notModifiedCount, + successCount: summary.successCount, + errorCount: summary.errorCount, + skippedCount: summary.skippedCount, + notModifiedCount: summary.notModifiedCount, + articlesCreated: summary.articlesCreated, + articlesUpdated: summary.articlesUpdated + ), + feeds: feedResults + ) + + try report.writeJSON(to: path) + print("📄 JSON report written to: \(path)") + } + + internal static func printSummary(feeds: [Feed], summary: UpdateSummary) { + print("\n" + String(repeating: "─", count: 50)) + print("📊 Update Summary") + print(" Total feeds: \(feeds.count)") + print(" ✅ Successful: \(summary.successCount)") + print(" ❌ Errors: \(summary.errorCount)") + print(" ⏭️ Skipped (robots.txt): \(summary.skippedCount)") + print(" ℹ️ Not modified (304): \(summary.notModifiedCount)") + if summary.articlesCreated > 0 || summary.articlesUpdated > 0 { + print(" 📝 Articles created: \(summary.articlesCreated)") + print(" 📝 Articles updated: \(summary.articlesUpdated)") + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift index 73fe7be5..f768ba79 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -32,31 +32,6 @@ import CelestraKit import Foundation import MistKit -/// Tracks update operation statistics -private struct UpdateSummary { - var successCount = 0 - var errorCount = 0 - var skippedCount = 0 - var notModifiedCount = 0 - var articlesCreated = 0 - var articlesUpdated = 0 - - mutating func record(_ result: FeedUpdateResult) { - switch result { - case .success(let created, let updated): - successCount += 1 - articlesCreated += created - articlesUpdated += updated - case .notModified: - notModifiedCount += 1 - case .skipped: - skippedCount += 1 - case .error: - errorCount += 1 - } - } -} - internal enum UpdateCommand { @available(macOS 13.0, *) internal static func run() async throws { @@ -184,7 +159,7 @@ internal enum UpdateCommand { UpdateReport.FeedResult( feedURL: feed.feedURL, recordName: feed.recordName ?? "unknown", - status: "error", + status: .error, articlesCreated: 0, articlesUpdated: 0, duration: Date().timeIntervalSince(feedStartTime), @@ -208,103 +183,4 @@ internal enum UpdateCommand { return (summary, feedResults) } - - private static func createFeedResult( - feed: Feed, - result: FeedUpdateResult, - duration: TimeInterval - ) -> UpdateReport.FeedResult { - switch result { - case .success(let created, let updated): - return UpdateReport.FeedResult( - feedURL: feed.feedURL, - recordName: feed.recordName ?? "unknown", - status: "success", - articlesCreated: created, - articlesUpdated: updated, - duration: duration, - error: nil - ) - case .notModified: - return UpdateReport.FeedResult( - feedURL: feed.feedURL, - recordName: feed.recordName ?? "unknown", - status: "notModified", - articlesCreated: 0, - articlesUpdated: 0, - duration: duration, - error: nil - ) - case .skipped(let reason): - return UpdateReport.FeedResult( - feedURL: feed.feedURL, - recordName: feed.recordName ?? "unknown", - status: "skipped", - articlesCreated: 0, - articlesUpdated: 0, - duration: duration, - error: reason - ) - case .error(let message): - return UpdateReport.FeedResult( - feedURL: feed.feedURL, - recordName: feed.recordName ?? "unknown", - status: "error", - articlesCreated: 0, - articlesUpdated: 0, - duration: duration, - error: message - ) - } - } - - private static func writeJSONReport( - config: CelestraConfiguration, - summary: UpdateSummary, - feedResults: [UpdateReport.FeedResult], - startTime: Date, - endTime: Date, - path: String - ) throws { - let report = UpdateReport( - startTime: startTime, - endTime: endTime, - configuration: UpdateReport.UpdateConfiguration( - delay: config.update.delay, - skipRobotsCheck: config.update.skipRobotsCheck, - maxFailures: config.update.maxFailures, - minPopularity: config.update.minPopularity, - limit: config.update.limit, - environment: config.cloudkit.environment == .production ? "production" : "development" - ), - summary: UpdateReport.Summary( - totalFeeds: summary.successCount + summary.errorCount - + summary.skippedCount + summary.notModifiedCount, - successCount: summary.successCount, - errorCount: summary.errorCount, - skippedCount: summary.skippedCount, - notModifiedCount: summary.notModifiedCount, - articlesCreated: summary.articlesCreated, - articlesUpdated: summary.articlesUpdated - ), - feeds: feedResults - ) - - try report.writeJSON(to: path) - print("📄 JSON report written to: \(path)") - } - - private static func printSummary(feeds: [Feed], summary: UpdateSummary) { - print("\n" + String(repeating: "─", count: 50)) - print("📊 Update Summary") - print(" Total feeds: \(feeds.count)") - print(" ✅ Successful: \(summary.successCount)") - print(" ❌ Errors: \(summary.errorCount)") - print(" ⏭️ Skipped (robots.txt): \(summary.skippedCount)") - print(" ℹ️ Not modified (304): \(summary.notModifiedCount)") - if summary.articlesCreated > 0 || summary.articlesUpdated > 0 { - print(" 📝 Articles created: \(summary.articlesCreated)") - print(" 📝 Articles updated: \(summary.articlesUpdated)") - } - } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift index d7669e30..5de33763 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -32,13 +32,13 @@ import Foundation /// Errors specific to feed update operations internal struct UpdateCommandError: LocalizedError { /// Number of feeds that encountered errors during update - let errorCount: Int + internal let errorCount: Int - var errorDescription: String? { + internal var errorDescription: String? { "\(errorCount) feed(s) encountered errors during update" } - var recoverySuggestion: String? { + internal var recoverySuggestion: String? { "Review error messages above for details and check CloudKit connectivity" } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift new file mode 100644 index 00000000..8ade5d61 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift @@ -0,0 +1,55 @@ +// +// UpdateSummary.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit + +/// Tracks update operation statistics +internal struct UpdateSummary { + internal var successCount = 0 + internal var errorCount = 0 + internal var skippedCount = 0 + internal var notModifiedCount = 0 + internal var articlesCreated = 0 + internal var articlesUpdated = 0 + + internal mutating func record(_ result: FeedUpdateResult) { + switch result { + case .success(let created, let updated): + successCount += 1 + articlesCreated += created + articlesUpdated += updated + case .notModified: + notModifiedCount += 1 + case .skipped: + skippedCount += 1 + case .error: + errorCount += 1 + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift new file mode 100644 index 00000000..4cd6e369 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift @@ -0,0 +1,111 @@ +// +// FeedUpdateProcessor+Fetch.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit +import CelestraKit +import Foundation +import MistKit + +@available(macOS 13.0, *) +extension FeedUpdateProcessor { + internal func processSuccessfulFetch( + feed: Feed, + recordName: String, + feedData: FeedData, + response: FetchResponse, + totalAttempts: Int64 + ) async throws -> FeedUpdateResult { + print(" ✅ Fetched: \(feedData.items.count) articles") + + // Sync articles via ArticleSyncService + let syncResult = try await articleSync.syncArticles( + items: feedData.items, + feedRecordName: recordName + ) + + // Print results for user feedback + print(" 📝 New: \(syncResult.newCount), Modified: \(syncResult.modifiedCount)") + if syncResult.created.failureCount > 0 { + print(" ⚠️ Failed to create \(syncResult.created.failureCount) articles") + } + if syncResult.updated.failureCount > 0 { + print(" ⚠️ Failed to update \(syncResult.updated.failureCount) articles") + } + + let metadata = metadataBuilder.buildSuccessMetadata( + feedData: feedData, + response: response, + feed: feed, + totalAttempts: totalAttempts + ) + return await updateFeedMetadata( + feed: feed, + recordName: recordName, + metadata: metadata, + articlesCreated: syncResult.created.successCount, + articlesUpdated: syncResult.updated.successCount + ) + } + + internal func updateFeedMetadata( + feed: Feed, + recordName: String, + metadata: FeedMetadataUpdate, + articlesCreated: Int, + articlesUpdated: Int + ) async -> FeedUpdateResult { + let updatedFeed = Feed( + recordName: feed.recordName, + recordChangeTag: feed.recordChangeTag, + feedURL: feed.feedURL, + title: metadata.title, + description: metadata.description, + isFeatured: feed.isFeatured, + isVerified: feed.isVerified, + subscriberCount: feed.subscriberCount, + totalAttempts: metadata.totalAttempts, + successfulAttempts: metadata.successfulAttempts, + lastAttempted: Date(), + isActive: feed.isActive, + etag: metadata.etag, + lastModified: metadata.lastModified, + failureCount: metadata.failureCount, + minUpdateInterval: metadata.minUpdateInterval + ) + do { + _ = try await service.updateFeed(recordName: recordName, feed: updatedFeed) + return metadata.failureCount == 0 + ? .success(articlesCreated: articlesCreated, articlesUpdated: articlesUpdated) + : .error(message: "Feed update had failures") + } catch { + print(" ⚠️ Failed to update feed metadata: \(error.localizedDescription)") + return .error(message: "Failed to update feed metadata: \(error.localizedDescription)") + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift index b1a2cca6..d9959e3b 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -36,12 +36,12 @@ import MistKit @available(macOS 13.0, *) internal struct FeedUpdateProcessor { internal let service: CloudKitService - private let fetcher: RSSFetcherService - private let robotsService: RobotsTxtService - private let rateLimiter: RateLimiter - private let skipRobotsCheck: Bool - private let articleSync: ArticleSyncService - private let metadataBuilder: FeedMetadataBuilder + internal let fetcher: RSSFetcherService + internal let robotsService: RobotsTxtService + internal let rateLimiter: RateLimiter + internal let skipRobotsCheck: Bool + internal let articleSync: ArticleSyncService + internal let metadataBuilder: FeedMetadataBuilder internal init( service: CloudKitService, @@ -132,36 +132,13 @@ internal struct FeedUpdateProcessor { return .notModified } - print(" ✅ Fetched: \(feedData.items.count) articles") - - // Sync articles via ArticleSyncService - let syncResult = try await articleSync.syncArticles( - items: feedData.items, - feedRecordName: recordName - ) - - // Print results for user feedback - print(" 📝 New: \(syncResult.newCount), Modified: \(syncResult.modifiedCount)") - if syncResult.created.failureCount > 0 { - print(" ⚠️ Failed to create \(syncResult.created.failureCount) articles") - } - if syncResult.updated.failureCount > 0 { - print(" ⚠️ Failed to update \(syncResult.updated.failureCount) articles") - } - - let metadata = metadataBuilder.buildSuccessMetadata( + return try await processSuccessfulFetch( + feed: feed, + recordName: recordName, feedData: feedData, response: response, - feed: feed, totalAttempts: totalAttempts ) - return await updateFeedMetadata( - feed: feed, - recordName: recordName, - metadata: metadata, - articlesCreated: syncResult.created.successCount, - articlesUpdated: syncResult.updated.successCount - ) } catch { print(" ❌ Error: \(error.localizedDescription)") let metadata = metadataBuilder.buildErrorMetadata( @@ -178,40 +155,4 @@ internal struct FeedUpdateProcessor { return .error(message: error.localizedDescription) } } - - private func updateFeedMetadata( - feed: Feed, - recordName: String, - metadata: FeedMetadataUpdate, - articlesCreated: Int, - articlesUpdated: Int - ) async -> FeedUpdateResult { - let updatedFeed = Feed( - recordName: feed.recordName, - recordChangeTag: feed.recordChangeTag, - feedURL: feed.feedURL, - title: metadata.title, - description: metadata.description, - isFeatured: feed.isFeatured, - isVerified: feed.isVerified, - subscriberCount: feed.subscriberCount, - totalAttempts: metadata.totalAttempts, - successfulAttempts: metadata.successfulAttempts, - lastAttempted: Date(), - isActive: feed.isActive, - etag: metadata.etag, - lastModified: metadata.lastModified, - failureCount: metadata.failureCount, - minUpdateInterval: metadata.minUpdateInterval - ) - do { - _ = try await service.updateFeed(recordName: recordName, feed: updatedFeed) - return metadata.failureCount == 0 - ? .success(articlesCreated: articlesCreated, articlesUpdated: articlesUpdated) - : .error(message: "Feed update had failures") - } catch { - print(" ⚠️ Failed to update feed metadata: \(error.localizedDescription)") - return .error(message: "Failed to update feed metadata: \(error.localizedDescription)") - } - } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift index c03e0c4b..a0143868 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -29,11 +29,24 @@ /// Result of processing a single feed update internal enum FeedUpdateResult: Sendable, Equatable { + // MARK: - Cases + case success(articlesCreated: Int, articlesUpdated: Int) case notModified case skipped(reason: String) case error(message: String) + // MARK: - Subtypes + + internal enum SimpleStatus { + case success + case notModified + case skipped + case error + } + + // MARK: - Properties + /// Simple status for backward compatibility internal var simpleStatus: SimpleStatus { switch self { @@ -47,11 +60,4 @@ internal enum FeedUpdateResult: Sendable, Equatable { return .error } } - - internal enum SimpleStatus { - case success - case notModified - case skipped - case error - } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift index 88cb83eb..648f902e 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -30,26 +30,6 @@ public import Foundation public import MistKit -// MARK: - Configuration Error - -/// Custom error for configuration issues (library-compatible) -public struct ConfigurationError: LocalizedError { - /// The error message describing what went wrong. - public let message: String - - /// A localized description of the error. - public var errorDescription: String? { - message - } - - /// Creates a new configuration error. - /// - /// - Parameter message: The error message describing what went wrong. - public init(_ message: String) { - self.message = message - } -} - // MARK: - Shared Configuration /// Shared configuration helper for creating CloudKit service @@ -69,11 +49,10 @@ public enum CelestraConfig { ) // Create and return CloudKit service - return try CloudKitService( + return CloudKitService( containerIdentifier: config.containerID, tokenManager: tokenManager, - environment: config.environment, - database: .public + environment: config.environment ) } @@ -113,11 +92,10 @@ public enum CelestraConfig { ) // Create and return CloudKit service - return try CloudKitService( + return CloudKitService( containerIdentifier: containerID, tokenManager: tokenManager, - environment: environment, - database: .public + environment: environment ) } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift index ad5df93a..4fc69465 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift index 8ce207d6..e386e838 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift index be27e6eb..05dfe132 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift index 295104c6..e3b4cdbf 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift index 66f304ba..460de999 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift index fcf26a54..6329bc5e 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift index f8aa4118..a3ef2f94 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift index 3af38354..3e395a0b 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation public import MistKit /// Validated CloudKit configuration with all required fields diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift index 80fed348..1bafdba7 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/ConfigurationError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/ConfigurationError.swift new file mode 100644 index 00000000..5c68c1d5 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/ConfigurationError.swift @@ -0,0 +1,48 @@ +// +// ConfigurationError.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Custom error for configuration issues (library-compatible) +public struct ConfigurationError: LocalizedError { + /// The error message describing what went wrong. + public let message: String + + /// A localized description of the error. + public var errorDescription: String? { + message + } + + /// Creates a new configuration error. + /// + /// - Parameter message: The error message describing what went wrong. + public init(_ message: String) { + self.message = message + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift index 33520116..bd526ac4 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,7 +28,7 @@ // public import CelestraKit -public import Foundation +internal import Foundation public import MistKit extension Article: CloudKitConvertible { diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift index 26db1a1b..09ba2c95 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,7 +28,7 @@ // public import CelestraKit -public import Foundation +internal import Foundation public import MistKit extension Feed: CloudKitConvertible { diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift index 754fd0eb..83e21cdc 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift index f3385481..4b290aeb 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift index 5c1a982b..3a0a88d3 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,7 +28,7 @@ // public import CelestraKit -public import Foundation +internal import Foundation public import MistKit /// Result of a batch CloudKit operation diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport+JSONOutput.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport+JSONOutput.swift new file mode 100644 index 00000000..68e6c067 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport+JSONOutput.swift @@ -0,0 +1,44 @@ +// +// UpdateReport+JSONOutput.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +// MARK: - JSON Output + +extension UpdateReport { + /// Write the report to a JSON file + public func writeJSON(to path: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + + let data = try encoder.encode(self) + try data.write(to: URL(fileURLWithPath: path)) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift index 37dfef2d..b298240b 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -31,41 +31,32 @@ public import Foundation /// Comprehensive report of feed update operations for JSON export public struct UpdateReport: Codable, Sendable { - /// When the update started - public let startTime: Date - - /// When the update completed - public let endTime: Date - - /// Total duration in seconds - public var duration: TimeInterval { - endTime.timeIntervalSince(startTime) - } - - /// Configuration used for this update - public let configuration: UpdateConfiguration - - /// Summary statistics - public let summary: Summary - - /// Detailed per-feed results - public let feeds: [FeedResult] + // MARK: - Subtypes /// Summary statistics for the update operation public struct Summary: Codable, Sendable { + // MARK: - Properties + + /// Total number of feeds processed. public let totalFeeds: Int + /// Number of feeds that updated successfully. public let successCount: Int + /// Number of feeds that encountered errors. public let errorCount: Int + /// Number of feeds that were skipped. public let skippedCount: Int + /// Number of feeds that returned not-modified responses. public let notModifiedCount: Int + /// Total number of articles created across all feeds. public let articlesCreated: Int + /// Total number of articles updated across all feeds. public let articlesUpdated: Int + /// Percentage of feeds that updated successfully (0-100). + public let successRate: Double - public var successRate: Double { - guard totalFeeds > 0 else { return 0 } - return Double(successCount) / Double(totalFeeds) * 100 - } + // MARK: - Lifecycle + /// Creates a new summary with the given statistics. public init( totalFeeds: Int, successCount: Int, @@ -82,18 +73,33 @@ public struct UpdateReport: Codable, Sendable { self.notModifiedCount = notModifiedCount self.articlesCreated = articlesCreated self.articlesUpdated = articlesUpdated + self.successRate = + totalFeeds > 0 + ? Double(successCount) / Double(totalFeeds) * 100 + : 0 } } - /// Configuration snapshot + /// Configuration snapshot used for an update run. public struct UpdateConfiguration: Codable, Sendable { + // MARK: - Properties + + /// Delay in seconds between feed updates. public let delay: Double + /// Whether robots.txt checking was skipped. public let skipRobotsCheck: Bool + /// Maximum number of consecutive failures before skipping a feed. public let maxFailures: Int? + /// Minimum subscriber count required to update a feed. public let minPopularity: Int? + /// Maximum number of feeds to process. public let limit: Int? + /// CloudKit environment used for this update. public let environment: String + // MARK: - Lifecycle + + /// Creates a new configuration snapshot. public init( delay: Double, skipRobotsCheck: Bool, @@ -111,20 +117,40 @@ public struct UpdateReport: Codable, Sendable { } } - /// Result for a single feed update + /// Result for a single feed update. public struct FeedResult: Codable, Sendable { + /// Outcome status for a feed update. + public enum Status: String, Codable, Sendable { + case success + case error + case skipped + case notModified + } + + // MARK: - Properties + + /// URL of the feed that was processed. public let feedURL: String + /// CloudKit record name for this feed. public let recordName: String - public let status: String // "success", "error", "skipped", "notModified" + /// Outcome status for this feed update. + public let status: Status + /// Number of new articles created for this feed. public let articlesCreated: Int + /// Number of existing articles updated for this feed. public let articlesUpdated: Int + /// Time in seconds taken to process this feed. public let duration: TimeInterval + /// Error message if the feed update failed, nil otherwise. public let error: String? + // MARK: - Lifecycle + + /// Creates a new feed result. public init( feedURL: String, recordName: String, - status: String, + status: Status, articlesCreated: Int, articlesUpdated: Int, duration: TimeInterval, @@ -140,6 +166,25 @@ public struct UpdateReport: Codable, Sendable { } } + // MARK: - Properties + + /// When the update started + public let startTime: Date + /// When the update completed + public let endTime: Date + /// Total duration in seconds + public let duration: TimeInterval + + /// Configuration used for this update + public let configuration: UpdateConfiguration + /// Summary statistics + public let summary: Summary + /// Detailed per-feed results + public let feeds: [FeedResult] + + // MARK: - Lifecycle + + /// Creates a new update report. public init( startTime: Date, endTime: Date, @@ -149,22 +194,9 @@ public struct UpdateReport: Codable, Sendable { ) { self.startTime = startTime self.endTime = endTime + self.duration = endTime.timeIntervalSince(startTime) self.configuration = configuration self.summary = summary self.feeds = feeds } } - -// MARK: - JSON Output - -extension UpdateReport { - /// Write the report to a JSON file - public func writeJSON(to path: String) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - - let data = try encoder.encode(self) - try data.write(to: URL(fileURLWithPath: path)) - } -} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift index 02db7e68..aa187d7c 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift index 90c66876..653c38f1 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -53,9 +53,79 @@ public protocol CloudKitRecordOperating: Sendable { /// - Returns: Array of modified record info /// - Throws: CloudKitError if the modification fails func modifyRecords(_ operations: [RecordOperation]) async throws(CloudKitError) -> [RecordInfo] + + /// Query all records of a type, automatically paginating through continuation markers + /// - Parameters: + /// - recordType: The type of record to query + /// - filters: Optional query filters + /// - sortBy: Optional sort descriptors + /// - pageSize: Maximum number of records per page (optional) + /// - desiredKeys: Optional list of field keys to fetch + /// - maxPages: Maximum number of pages to fetch before throwing + /// - Returns: Array of all matching record info across all pages + /// - Throws: CloudKitError if the query fails + func queryAllRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + pageSize: Int?, + desiredKeys: [String]?, + maxPages: Int + ) async throws(CloudKitError) -> [RecordInfo] } // MARK: - CloudKitService Conformance @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService: CloudKitRecordOperating {} +extension CloudKitService: CloudKitRecordOperating { + /// Satisfy CloudKitRecordOperating protocol by forwarding to modifyRecords(_:atomic:) + public func modifyRecords(_ operations: [RecordOperation]) async throws(CloudKitError) + -> [RecordInfo] + { + try await modifyRecords( + operations, + atomic: false, + database: .public(.prefers(.serverToServer)) + ) + } + + /// Satisfy CloudKitRecordOperating's `queryRecords` (no database param) by forwarding to the public-database overload. + public func queryRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + limit: Int?, + desiredKeys: [String]? + ) async throws(CloudKitError) -> [RecordInfo] { + let result: QueryResult = try await queryRecords( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: limit, + desiredKeys: desiredKeys, + continuationMarker: nil, + database: .public(.prefers(.serverToServer)) + ) + return result.records + } + + /// Satisfy CloudKitRecordOperating's `queryAllRecords` (no database param) by forwarding to the public-database overload. + public func queryAllRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + pageSize: Int?, + desiredKeys: [String]?, + maxPages: Int + ) async throws(CloudKitError) -> [RecordInfo] { + try await queryAllRecords( + recordType: recordType, + filters: filters, + sortBy: sortBy, + pageSize: pageSize, + desiredKeys: desiredKeys, + maxPages: maxPages, + database: .public(.prefers(.serverToServer)) + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift index 5679a366..c92b5eb8 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift index a8ec79d5..dff70fdd 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift @@ -3,11 +3,11 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -98,7 +98,9 @@ public struct ArticleCloudKitService: Sendable { return [] } var allArticles: [Article] = [] - let guidBatches = guids.chunked(into: guidQueryBatchSize) + let guidBatches = stride(from: 0, to: guids.count, by: guidQueryBatchSize).map { + Array(guids[$0.. = .success([]) + var modifyRecordsResult: Result<[RecordInfo], CloudKitError> = .success([]) + } + + // MARK: - Properties + + private let state = Mutex(State()) + + internal var queryCalls: [QueryCall] { + state.withLock { $0.queryCalls } + } + + internal var modifyCalls: [ModifyCall] { + state.withLock { $0.modifyCalls } + } - // MARK: - Stubbed Results + internal var queryRecordsResult: Result<[RecordInfo], CloudKitError> { + get { state.withLock { $0.queryRecordsResult } } + set { state.withLock { $0.queryRecordsResult = newValue } } + } - internal var queryRecordsResult: Result<[RecordInfo], CloudKitError> = .success([]) - internal var modifyRecordsResult: Result<[RecordInfo], CloudKitError> = .success([]) + internal var modifyRecordsResult: Result<[RecordInfo], CloudKitError> { + get { state.withLock { $0.modifyRecordsResult } } + set { state.withLock { $0.modifyRecordsResult = newValue } } + } // MARK: - CloudKitRecordOperating @@ -65,22 +92,51 @@ internal final class MockCloudKitRecordOperator: CloudKitRecordOperating, @unche limit: Int?, desiredKeys: [String]? ) async throws(CloudKitError) -> [RecordInfo] { - queryCalls.append( - QueryCall( - recordType: recordType, - filters: filters, - sortBy: sortBy, - limit: limit, - desiredKeys: desiredKeys + let result = state.withLock { state -> Result<[RecordInfo], CloudKitError> in + state.queryCalls.append( + QueryCall( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: limit, + desiredKeys: desiredKeys + ) ) - ) - return try queryRecordsResult.get() + return state.queryRecordsResult + } + return try result.get() } internal func modifyRecords(_ operations: [RecordOperation]) async throws(CloudKitError) -> [RecordInfo] { - modifyCalls.append(ModifyCall(operations: operations)) - return try modifyRecordsResult.get() + let result = state.withLock { state -> Result<[RecordInfo], CloudKitError> in + state.modifyCalls.append(ModifyCall(operations: operations)) + return state.modifyRecordsResult + } + return try result.get() + } + + internal func queryAllRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + pageSize: Int?, + desiredKeys: [String]?, + maxPages: Int + ) async throws(CloudKitError) -> [RecordInfo] { + let result = state.withLock { state -> Result<[RecordInfo], CloudKitError> in + state.queryCalls.append( + QueryCall( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: pageSize, + desiredKeys: desiredKeys + ) + ) + return state.queryRecordsResult + } + return try result.get() } } diff --git a/Examples/CelestraCloud/mise.toml b/Examples/CelestraCloud/mise.toml new file mode 100644 index 00000000..9be8b4f9 --- /dev/null +++ b/Examples/CelestraCloud/mise.toml @@ -0,0 +1,8 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4" +"spm:apple/swift-openapi-generator" = "1.10.3" diff --git a/Examples/MistDemo/.env.example b/Examples/MistDemo/.env.example new file mode 100644 index 00000000..7518b737 --- /dev/null +++ b/Examples/MistDemo/.env.example @@ -0,0 +1,17 @@ +# Copy this file to `.env` (gitignored) and fill in the values below. +# `make generate` sources .env into the shell so XcodeGen can substitute +# any ${VAR} into project.yml and bake it into the generated Xcode project. + +# The *public* CloudKit API token from CloudKit Dashboard for the container +# iCloud.com.brightdigit.MistDemo — the same value the MistDemo CLI reads +# from $CLOUDKIT_API_TOKEN. Substituted into the scheme's environmentVariables. +CLOUDKIT_API_TOKEN= + +# Bundle ID prefix used in project.yml. Override if you don't have access +# to the BrightDigit signing identity (e.g. set this to your reverse-DNS +# org prefix). The full bundle ID becomes ${BUNDLE_ID_PREFIX}.MistDemoApp. +BUNDLE_ID_PREFIX=com.brightdigit + +# Apple Developer team ID for Xcode automatic signing (e.g. ABCDE12345). +# Leave blank to fall back to whatever Xcode picks for the active account. +DEVELOPMENT_TEAM= diff --git a/Examples/MistDemo/.periphery.yml b/Examples/MistDemo/.periphery.yml new file mode 100644 index 00000000..85b884af --- /dev/null +++ b/Examples/MistDemo/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/Examples/MistDemo/.swift-format b/Examples/MistDemo/.swift-format new file mode 100644 index 00000000..5c31a3e1 --- /dev/null +++ b/Examples/MistDemo/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} \ No newline at end of file diff --git a/Examples/MistDemo/.swiftlint.yml b/Examples/MistDemo/.swiftlint.yml new file mode 100644 index 00000000..c33a1a85 --- /dev/null +++ b/Examples/MistDemo/.swiftlint.yml @@ -0,0 +1,168 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - Mint +indentation_width: + indentation_width: 2 +file_name: + severity: error + excluded: + - Package.swift + - AsyncHelpers.swift + - UserInfoTestExtension.swift + - TableFormatterTests+EdgeCases+FieldTypes.swift + - TableFormatterTests+EdgeCases+Whitespace.swift + - YAMLFormatterTests+YAMLEscaping+ReservedStrings.swift + - YAMLFormatterTests+YAMLEscaping+SpecialChars.swift + - ConfigKey+MistDemoTests.swift + - ConfigKey+MistDemoTests+BooleanConfigKeyWithPrefix.swift + - ConfigKey+MistDemoTests+ConfigKeyWithPrefix.swift + - ConfigKey+MistDemoTests+EdgeCases.swift + - ConfigKey+MistDemoTests+OptionalConfigKeyWithPrefix.swift + - ConfigKey+MistDemoTests+RealWorldUsage.swift + - FieldValue+FieldTypeTests.swift + - FieldValue+FieldTypeTests+BytesType.swift + - FieldValue+FieldTypeTests+DoubleType.swift + - FieldValue+FieldTypeTests+Int64Type.swift + - FieldValue+FieldTypeTests+InvalidTypeConversion.swift + - FieldValue+FieldTypeTests+StringType.swift + - FieldValue+FieldTypeTests+TimestampDateType.swift + - FieldValue+FieldTypeTests+UnsupportedType.swift + - EnvironmentTraits.swift + - MistDemoApp.swift +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/Examples/MistDemo/App/MistDemoApp.swift b/Examples/MistDemo/App/MistDemoApp.swift new file mode 100644 index 00000000..ea52376a --- /dev/null +++ b/Examples/MistDemo/App/MistDemoApp.swift @@ -0,0 +1,35 @@ +// +// MistDemoApp.swift +// MistDemoApp +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import MistDemoApp +import SwiftUI + +@main +internal struct MistDemoAppMain: AppMain { +} diff --git a/Examples/MistDemo/Makefile b/Examples/MistDemo/Makefile new file mode 100644 index 00000000..f87b0236 --- /dev/null +++ b/Examples/MistDemo/Makefile @@ -0,0 +1,19 @@ +.PHONY: generate clean help + +# Source .env (if present) so XcodeGen can substitute ${CLOUDKIT_API_TOKEN} +# and any other variables in project.yml. Falls back to the calling shell's +# environment when .env is absent (CI, ad-hoc `export`, etc.). +generate: + @if [ -f .env ]; then \ + echo "Sourcing .env"; \ + set -a && . ./.env && set +a; \ + fi; \ + xcodegen generate + +clean: + rm -rf MistDemoApp.xcodeproj + +help: + @echo "Targets:" + @echo " generate Source .env (if present) and run xcodegen generate" + @echo " clean Remove the generated MistDemoApp.xcodeproj" diff --git a/Examples/MistDemo/MistDemoApp.entitlements b/Examples/MistDemo/MistDemoApp.entitlements new file mode 100644 index 00000000..66b80d9b --- /dev/null +++ b/Examples/MistDemo/MistDemoApp.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.icloud-container-identifiers + + iCloud.com.brightdigit.MistDemo + + com.apple.developer.icloud-services + + CloudKit + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Examples/MistDemo/Package.resolved b/Examples/MistDemo/Package.resolved index c23cdde8..2fa36330 100644 --- a/Examples/MistDemo/Package.resolved +++ b/Examples/MistDemo/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "33fd915476a7cdcedb724c6d792f6b5a583243f1ac2482c608d8de3f342a8328", + "originHash" : "7284c3deec21f39c02edfa30e7214ff910bbb668d02643c0e02f07ab3341122d", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "b2faff932b956df50668241d14f1b42f7bae12b4", - "version" : "1.30.0" + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/hummingbird.git", "state" : { - "revision" : "63689a57cbebf72c50cb9d702a4c69fb79f51d5d", - "version" : "2.17.0" + "revision" : "a2ed0a0294de56e18ba55344eafc801a7a385a90", + "version" : "2.22.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", - "version" : "1.5.0" + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "2773d4125311133a2f705ec374c363a935069d45", - "version" : "1.1.0" + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "66a8512c4e7466582bab21e0e0c333f01974e5b6", - "version" : "1.16.0" + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" } }, { @@ -69,17 +69,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { "identity" : "swift-configuration", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-configuration.git", + "location" : "https://github.com/apple/swift-configuration", "state" : { - "revision" : "6ffef195ed4ba98ee98029970c94db7edc60d4c6", - "version" : "1.0.1" + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", - "version" : "1.3.1" + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", - "version" : "1.6.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", - "version" : "2.7.1" + "revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5", + "version" : "2.10.1" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "3eea09220e07d34ace722221cbda90306f48c86c", - "version" : "2.90.1" + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "7ee281d816fa8e5f3967a2c294035a318ea551c7", - "version" : "1.31.0" + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", - "version" : "1.39.0" + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "173cc69a058623525a58ae6710e2f5727c663793", - "version" : "2.36.0" + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "60c3e187154421171721c1a38e800b390680fb5d", - "version" : "1.26.0" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", - "version" : "1.9.0" + "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", + "version" : "1.11.0" } }, { @@ -204,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" + "revision" : "576a65b4ffb8c12ddad4950dc21eea2ef071bec2", + "version" : "1.3.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-service-context.git", "state" : { - "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", - "version" : "1.2.1" + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" } }, { @@ -222,17 +222,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", - "version" : "2.9.1" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", + "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } } ], diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 32ebf017..ab997e79 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -4,6 +4,21 @@ import PackageDescription +// MARK: - AsyncAlgorithms wasi gating +// +// AsyncAlgorithms 1.0.x's Locking.swift references pthread_mutex_*. The Swift 6.2 +// wasm32-unknown-wasip1 SDK doesn't ship libwasi-emulated-pthread.a, so linking +// fails. Swift 6.3+ wasi SDKs link cleanly. Gate the wasi exclusion to 6.2 only; +// the `#else` self-deletes when the floor moves to 6.3. + +#if compiler(>=6.3) +let asyncAlgorithmsCondition: TargetDependencyCondition? = nil +#else +let asyncAlgorithmsCondition: TargetDependencyCondition? = .when( + platforms: Platform.without(.wasi) +) +#endif + // MARK: - Swift Settings Configuration let swiftSettings: [SwiftSetting] = [ @@ -72,57 +87,134 @@ let swiftSettings: [SwiftSetting] = [ // Warn about functions with >100 lines "-Xfrontend", "-warn-long-function-bodies=100", // Warn about slow type checking expressions - "-Xfrontend", "-warn-long-expression-type-checking=100" - ]) + "-Xfrontend", "-warn-long-expression-type-checking=100", + ]), ] let package = Package( - name: "MistDemo", - platforms: [ - .macOS(.v15) - ], - products: [ - .executable(name: "mistdemo", targets: ["MistDemo"]) - ], - dependencies: [ - .package(path: "../.."), // MistKit - .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), - .package( - url: "https://github.com/apple/swift-configuration", - from: "1.0.0", - traits: ["CommandLineArguments"] + name: "MistDemo", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + products: [ + .executable(name: "mistdemo", targets: ["MistDemo"]), + .library(name: "MistDemoApp", targets: ["MistDemoApp"]), + ], + dependencies: [ + .package(name: "MistKit", path: "../.."), + .package( + url: "https://github.com/hummingbird-project/hummingbird.git", + from: "2.0.0" + ), + .package( + url: "https://github.com/apple/swift-configuration", + from: "1.0.0", + traits: ["CommandLineArguments"] + ), + .package( + url: "https://github.com/swift-server/swift-service-lifecycle.git", + from: "2.0.0" + ), + .package( + url: "https://github.com/apple/swift-async-algorithms.git", + from: "1.0.0" + ), + ], + targets: [ + .target( + name: "ConfigKeyKit", + dependencies: [], + swiftSettings: swiftSettings + ), + .target( + name: "MistDemoApp", + dependencies: ["MistDemoKit"], + swiftSettings: swiftSettings + ), + .target( + name: "MistDemoKit", + dependencies: [ + "ConfigKeyKit", + .product(name: "MistKit", package: "MistKit"), + .product( + name: "Hummingbird", + package: "hummingbird", + condition: .when(platforms: [ + .macOS, .iOS, .tvOS, .visionOS, .macCatalyst, .linux, + ]) + ), + .product(name: "Configuration", package: "swift-configuration"), + .product( + name: "UnixSignals", + package: "swift-service-lifecycle" + ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms", + condition: asyncAlgorithmsCondition ), - .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0") - ], - targets: [ - .target( - name: "ConfigKeyKit", - dependencies: [], - swiftSettings: swiftSettings + ], + resources: [ + .copy("Resources/index.html"), + ], + swiftSettings: swiftSettings + ), + .executableTarget( + name: "MistDemo", + dependencies: [ + "MistDemoKit", + "ConfigKeyKit", + .product(name: "MistKit", package: "MistKit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MistDemoTests", + dependencies: [ + "MistDemoKit", + "ConfigKeyKit", + .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitOpenAPI", package: "MistKit"), + .product( + name: "Hummingbird", + package: "hummingbird", + condition: .when(platforms: [ + .macOS, .iOS, .tvOS, .visionOS, .macCatalyst, .linux, + ]) ), - .executableTarget( - name: "MistDemo", - dependencies: [ - "ConfigKeyKit", - .product(name: "MistKit", package: "MistKit"), - .product(name: "Hummingbird", package: "hummingbird"), - .product(name: "Configuration", package: "swift-configuration"), - .product(name: "UnixSignals", package: "swift-service-lifecycle") - ], - resources: [ - .copy("Resources") - ], - swiftSettings: swiftSettings + .product( + name: "HummingbirdTesting", + package: "hummingbird", + condition: .when(platforms: [ + .macOS, .iOS, .tvOS, .visionOS, .macCatalyst, .linux, + ]) ), - .testTarget( - name: "MistDemoTests", - dependencies: [ - "MistDemo", - "ConfigKeyKit", - .product(name: "MistKit", package: "MistKit") - ], - swiftSettings: swiftSettings - ) - ] + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms", + condition: asyncAlgorithmsCondition + ), + ], + swiftSettings: swiftSettings + ), + ] ) + +extension Platform { + static let all: [Platform] = [ + .macOS, .iOS, .tvOS, .watchOS, .visionOS, .macCatalyst, + .linux, .windows, .android, .driverKit, .wasi, + ] + + static func without(_ platform: Platform) -> [Platform] { + var result = all + result.removeAll { $0 == platform } + return result + } +} + // swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md new file mode 100644 index 00000000..87e199a0 --- /dev/null +++ b/Examples/MistDemo/README.md @@ -0,0 +1,271 @@ +# MistDemo + +Three runnable demos that exercise the same CloudKit container from +three different stacks, intended to be shown side-by-side: + +| Surface | Stack | Use case | +|---|---|---| +| `mistdemo` CLI (`query`, `create`, `update`, `delete`, …) | MistKit (CloudKit Web Services REST) | Command-line, scripts, CI, Linux | +| `mistdemo web` | MistKit + Hummingbird server + browser UI | Interactive demo, presentations | +| `MistDemoApp` | Apple CloudKit framework (`CKContainer`, `CKDatabase`) | Native macOS / iOS apps | + +All three target the container `iCloud.com.brightdigit.MistDemo` and the +same `Note` record schema (see [`schema.ckdb`](schema.ckdb)). The same +`$CLOUDKIT_API_TOKEN` covers the CLI/web and is also exchanged for a +web-auth token by the native app, so one source of credentials feeds +every surface. + +## Prerequisites + +1. An Apple Developer account with a CloudKit container. +2. A CloudKit **API token** for that container (from the CloudKit + Console). The web and native demos use the web-auth flow, so + server-to-server signing keys are not needed. +3. Swift 6+ toolchain (for the CLI/web). The native app additionally + requires Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +--- + +## CLI — `mistdemo` + +The CLI is the broadest surface — every CloudKit operation MistKit +supports has a subcommand. See `swift run mistdemo --help` for the full +list. The most common commands: + +```bash +cd Examples/MistDemo +swift run mistdemo query --record-type Note +swift run mistdemo create --record-type Note --fields '{"title":"Hi"}' +swift run mistdemo auth-token # capture a web-auth token +swift run mistdemo test-public # integration suite, public DB +swift run mistdemo test-private # integration suite, private DB +``` + +Configuration comes from `MistDemoConfiguration` — flags, +`CLOUDKIT_*` env vars, or `--config-file ~/.mistdemo/config.json` all +work. + +--- + +## Web — `mistdemo web` + +A long-running Hummingbird server that pairs the CloudKit browser-side +auth round trip with a CRUD UI driven by MistKit on the server. Run +`mistdemo web`, complete the iCloud sign-in in the browser, then drive +record create / query / update / delete from the same page until you +Ctrl+C the server. + +### Quick start + +```bash +cd Examples/MistDemo +swift run mistdemo web --api-token "$CLOUDKIT_API_TOKEN" +``` + +Or via env var: + +```bash +CLOUDKIT_API_TOKEN=… swift run mistdemo web +``` + +The CLI prints the server URL. The `web` command does **not** open the +browser by default (the server is long-running and often driven from a +different machine); pass `--browser` to opt in. The `auth-token` command +**does** open the browser by default — the captured token is the whole +point of running it. Sign in with your Apple ID; the server captures the +web-auth token and the CRUD UI on the page becomes live. + +### Options + +| Flag | Default | Notes | +|---|---|---| +| `--api-token ` | (required) | Or set `CLOUDKIT_API_TOKEN` | +| `--container-identifier ` | `iCloud.com.brightdigit.MistDemo` | Your CloudKit container | +| `--environment ` | `development` | `development` or `production` | +| `--host ` | `127.0.0.1` | Bind address | +| `--port ` | `8080` | Server port | +| `--browser` | on for `auth-token`, off for `web` | Open browser on startup | +| `--no-browser` | — | Suppress the open (wins if both flags set) | + +Configuration is read via `MistDemoConfiguration`, so the same keys +(`api.token`, `container.identifier`, `environment`, `port`, `host`, +`browser`, `no.browser`) can be supplied through `--config-file ~/.mistdemo/config.json` +or environment variables. + +### What the server exposes + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/` and `/index.html` | Interactive demo page | +| `GET` | `/api/config` | CloudKit JS config (loopback-only) | +| `POST` | `/api/authenticate` | Capture web-auth token from the browser | +| `POST` | `/api/records/query` | Query records | +| `POST` | `/api/records/create` | Create record | +| `POST` | `/api/records/update` | Update record | +| `POST` | `/api/records/delete` | Delete record | + +The page has a **mode toggle** that compares the two stacks against the +same container: + +- **MistKit (server-side)** — the page calls `/api/records/*` on this + server, which talks to CloudKit Web Services via MistKit. +- **CloudKit JS (browser-side)** — the page talks directly to CloudKit + from the browser using the config returned by `/api/config`. + +### Calling the API directly + +Once the browser has completed the auth round trip, the same endpoints +can be exercised from a terminal: + +```bash +curl -X POST http://127.0.0.1:8080/api/records/query \ + -H 'Content-Type: application/json' \ + -d '{"recordType":"Note"}' +``` + +### Tests + +```bash +cd Examples/MistDemo +swift test --filter WebServerTests +swift test --filter WebAuthTokenStoreTests +``` + +`WebServerTests` uses `MockBackend` to drive the routes without +hitting CloudKit. `WebAuthTokenStoreTests` covers the token-capture +stream that backs the auth response. + +### Layout + +The web command's code lives under `Sources/MistDemoKit/`: + +``` +Sources/MistDemoKit/ +├── Commands/WebCommand.swift # `mistdemo web` entry point +├── Configuration/WebConfig.swift # Flags / env / config-file binding +├── Resources/index.html # Served at GET / +└── Server/ + ├── WebServer.swift # Hummingbird router + handlers + ├── WebBackend.swift # MistKit-backed backend + ├── WebRequests.swift # Request payloads + ├── WebResponse.swift # Response payloads + ├── WebIndexHTML.swift # Loads index HTML from Bundle.module + └── WebAuthTokenStore.swift # Captures the token from /api/authenticate +``` + +Tests are under `Tests/MistDemoTests/Server/`. + +### Security notes + +- The server binds to `127.0.0.1` by default and rejects non-loopback + requests to `/api/config`. Override `--host` with care. +- The web-auth token is short-lived. Re-run `mistdemo web` to refresh it. +- Never commit your CloudKit API token; prefer `CLOUDKIT_API_TOKEN` or a + config file outside the repo. + +--- + +## Native app — `MistDemoApp` + +A SwiftUI demo app that talks to the same CloudKit container, but uses +**Apple's native CloudKit framework** (`CKContainer`, `CKDatabase`, +`CKQuery`) instead of MistKit. + +### What's included (read-side parity with the CLI) + +- **iCloud Account view** — `CKContainer.accountStatus()` +- **Zones list** — `CKDatabase.allRecordZones()` (parity with `mistdemo lookup-zones`) +- **Notes query** — `CKDatabase.records(matching:)` for `Note` records, sorted by `index` +- **Note detail** — typed view of `title`, `index`, `image`; created/modified come from CloudKit system metadata +- **Create / update / delete** — `CKDatabase.save(_:)` and `deleteRecord(withID:)` + +The `Note` model in `Sources/MistDemoApp/Models/CloudKitModels.swift` +mirrors the `Note` record type in `schema.ckdb`. + +### Layout + +The reusable code lives in the `MistDemoApp` library target of the +local Swift package. The Xcode project only references a thin `@main` +shell: + +``` +Examples/MistDemo/ +├── Package.swift # mistdemo CLI + MistDemoApp library +├── project.yml # XcodeGen config +├── App/ +│ └── MistDemoApp.swift # @main App + WindowGroup +├── Sources/ +│ ├── MistDemo/ # CLI entry point +│ ├── MistDemoKit/ # CLI library (used by mistdemo) +│ ├── ConfigKeyKit/ # Configuration parsing +│ └── MistDemoApp/ # SwiftUI library used by the Xcode app +│ ├── Models/CloudKitModels.swift +│ ├── Services/NativeCloudKitService.swift +│ └── Views/{RootView,AccountView,ZoneListView,QueryView,NoteEditView,RecordDetailView}.swift +└── schema.ckdb # CloudKit schema for Note record +``` + +The same `MistDemoApp` source files compile for both macOS and iOS; +only `App/MistDemoApp.swift`'s `defaultSize(...)` is gated to macOS. + +### Recommended path: open in Xcode + +CloudKit requires an `.app` bundle with the iCloud + CloudKit +entitlement. The Xcode project is generated from `project.yml` via +[XcodeGen](https://github.com/yonaskolb/XcodeGen): + +```bash +brew install xcodegen # one-time +cd Examples/MistDemo +cp .env.example .env # one-time — fill in CLOUDKIT_API_TOKEN, BUNDLE_ID_PREFIX, DEVELOPMENT_TEAM +make generate # sources .env, runs xcodegen +open MistDemoApp.xcodeproj +``` + +Two schemes ship in the project: + +- `MistDemoApp-macOS` — runs as a native macOS app +- `MistDemoApp-iOS` — runs on iOS / iPadOS (simulator or device) + +Before running, in **Signing & Capabilities** for each target, sign in +to your Apple Developer account so Xcode can request the `iCloud + +CloudKit` entitlement against the +`iCloud.com.brightdigit.MistDemo` container. + +The entitlements file (`MistDemoApp.entitlements`) is checked in and +already lists the container. If you don't have access to the +BrightDigit signing identity, set `BUNDLE_ID_PREFIX` in `.env` to a +prefix you own and `DEVELOPMENT_TEAM` to your team ID before running +`make generate`. + +### Setting the CloudKit API token + +The app's iCloud Account view exchanges your **public CloudKit API +token** (from CloudKit Dashboard) for a web auth token via +`CKFetchWebAuthTokenOperation`. The token is the same value the +CLI/web reads from `$CLOUDKIT_API_TOKEN`, so one source covers every +surface. + +There are three ways to provide it, ranked by ergonomics: + +1. **`.env` → `make generate` (recommended).** Copy `.env.example` to + `.env` (gitignored) and fill in `CLOUDKIT_API_TOKEN`. Then run + `make generate` from `Examples/MistDemo`. The Makefile sources + `.env`; XcodeGen substitutes `${CLOUDKIT_API_TOKEN}` into the + generated scheme's `environmentVariables`, so when you run the app + from Xcode the value reaches it through + `ProcessInfo.processInfo.environment`. The whole `.xcodeproj` is + gitignored repo-wide, so the substituted value never lands in git. + Survives Xcode debug runs and iOS Simulator runs. + +2. **Ad-hoc terminal env var.** Useful when launching from a shell: + `CLOUDKIT_API_TOKEN= open MistDemoApp.xcodeproj`. The app + reads `ProcessInfo.processInfo.environment` on launch. + +3. **Manual paste in the app.** The TextField in iCloud Account still + accepts ad-hoc values; they persist via `@AppStorage` + (`UserDefaults`) until cleared. + +The `.env` file is gitignored, the `.xcodeproj` is gitignored repo-wide, +and `.env.example` only names the variable — so the secret never lands +in the repo at any stage of the pipeline. diff --git a/Examples/MistDemo/Scripts/header.sh b/Examples/MistDemo/Scripts/header.sh new file mode 100755 index 00000000..2242c437 --- /dev/null +++ b/Examples/MistDemo/Scripts/header.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template using a heredoc +read -r -d '' header_template <<'EOF' +// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +EOF + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + # Escape % characters in user-provided values to prevent format specifier injection + filename=$(basename "$file" | sed 's/%/%%/g') + package_safe=$(printf '%s' "$package" | sed 's/%/%%/g') + creator_safe=$(printf '%s' "$creator" | sed 's/%/%%/g') + year_safe=$(printf '%s' "$year" | sed 's/%/%%/g') + company_safe=$(printf '%s' "$company" | sed 's/%/%%/g') + + header=$(printf "$header_template" "$filename" "$package_safe" "$creator_safe" "$year_safe" "$company_safe") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Examples/MistDemo/Scripts/lint.sh b/Examples/MistDemo/Scripts/lint.sh new file mode 100755 index 00000000..5ddcd68d --- /dev/null +++ b/Examples/MistDemo/Scripts/lint.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + if [ "$LINT_MODE" = "STRICT" ]; then + "$@" || ERRORS=$((ERRORS + 1)) + else + "$@" + fi +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" + STRINGSLINT_OPTIONS="--config .strict.stringslint.yml" +else + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" + STRINGSLINT_OPTIONS="--config .stringslint.yml" +fi + +pushd $PACKAGE_DIR + +if [ -z "$CI" ]; then + run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "MistDemo" + +# Generated files now automatically include ignore directives via OpenAPI generator configuration + +if [ -z "$CI" ]; then + run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/Command.swift b/Examples/MistDemo/Sources/ConfigKeyKit/Command.swift index 693b23c0..89506fa7 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/Command.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/Command.swift @@ -1,6 +1,6 @@ // // Command.swift -// ConfigKeyKit +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -31,31 +31,31 @@ import Foundation /// Generic protocol for CLI commands using Swift Configuration public protocol Command: Sendable { - /// Associated configuration type for this command - associatedtype Config: ConfigurationParseable - - /// Command name for CLI parsing - static var commandName: String { get } - - /// Abstract description of the command - static var abstract: String { get } - - /// Detailed help text for the command - static var helpText: String { get } - - /// Initialize command with configuration - init(config: Config) - - /// Execute the command asynchronously - func execute() async throws - - /// Create a command instance with configuration - static func createInstance() async throws -> Self + /// Associated configuration type for this command + associatedtype Config: ConfigurationParseable + + /// Command name for CLI parsing + static var commandName: String { get } + + /// Abstract description of the command + static var abstract: String { get } + + /// Detailed help text for the command + static var helpText: String { get } + + /// Initialize command with configuration + init(config: Config) + + /// Create a command instance with configuration + static func createInstance() async throws -> Self + + /// Execute the command asynchronously + func execute() async throws } -public extension Command { - /// Print help information for this command - static func printHelp() { - print(helpText) - } -} \ No newline at end of file +extension Command { + /// Print help information for this command + public static func printHelp() { + print(helpText) + } +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/CommandConfiguration.swift b/Examples/MistDemo/Sources/ConfigKeyKit/CommandConfiguration.swift index 03ffdcd0..c3c21924 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/CommandConfiguration.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/CommandConfiguration.swift @@ -1,6 +1,6 @@ // // CommandConfiguration.swift -// ConfigKeyKit +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -29,11 +29,14 @@ /// Command configuration for identifying and routing commands public struct CommandConfiguration { - public let commandName: String - public let abstract: String - - public init(commandName: String, abstract: String) { - self.commandName = commandName - self.abstract = abstract - } -} \ No newline at end of file + /// The name used to invoke this command on the CLI. + public let commandName: String + /// A short description of what the command does. + public let abstract: String + + /// Initialize with the command name and abstract description. + public init(commandName: String, abstract: String) { + self.commandName = commandName + self.abstract = abstract + } +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/CommandLineParser.swift b/Examples/MistDemo/Sources/ConfigKeyKit/CommandLineParser.swift index a7c2f8ad..b5457109 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/CommandLineParser.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/CommandLineParser.swift @@ -1,6 +1,6 @@ // // CommandLineParser.swift -// ConfigKeyKit +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -31,44 +31,49 @@ import Foundation /// Command line argument parser for Swift Configuration integration public struct CommandLineParser { - private let arguments: [String] - - public init(arguments: [String] = CommandLine.arguments) { - self.arguments = arguments + private let arguments: [String] + + /// Initialize with command line arguments. + public init(arguments: [String] = CommandLine.arguments) { + self.arguments = arguments + } + + /// Parse the command name from command line arguments + public func parseCommandName() -> String? { + // Skip the executable name (first argument) + guard arguments.count > 1 else { + return nil } - - /// Parse the command name from command line arguments - public func parseCommandName() -> String? { - // Skip the executable name (first argument) - guard arguments.count > 1 else { return nil } - let commandCandidate = arguments[1] - - // If it starts with '--', it's not a command but a global option - if commandCandidate.hasPrefix("--") { - return nil - } - - return commandCandidate + let commandCandidate = arguments[1] + + // If it starts with '--', it's not a command but a global option + if commandCandidate.hasPrefix("--") { + return nil } - - /// Get all arguments after the command name for command-specific parsing - public func commandArguments() -> [String] { - guard arguments.count > 1 else { return [] } - let commandName = arguments[1] - - // If first argument is an option, return all arguments for global parsing - if commandName.hasPrefix("--") { - return Array(arguments.dropFirst()) - } - - // Return arguments after command name - return Array(arguments.dropFirst(2)) + + return commandCandidate + } + + /// Get all arguments after the command name for command-specific parsing + public func commandArguments() -> [String] { + guard arguments.count > 1 else { + return [] } - - /// Check if help was requested - public func isHelpRequested() -> Bool { - arguments.contains { arg in - arg == "--help" || arg == "-h" || arg == "help" - } + let commandName = arguments[1] + + // If first argument is an option, return all arguments for global parsing + if commandName.hasPrefix("--") { + return Array(arguments.dropFirst()) + } + + // Return arguments after command name + return Array(arguments.dropFirst(2)) + } + + /// Check if help was requested + public func isHelpRequested() -> Bool { + arguments.contains { arg in + arg == "--help" || arg == "-h" || arg == "help" } -} \ No newline at end of file + } +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistry.swift b/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistry.swift index 39f9e8ed..1d93e29c 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistry.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistry.swift @@ -1,6 +1,6 @@ // // CommandRegistry.swift -// ConfigKeyKit +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -31,58 +31,63 @@ import Foundation /// Actor-based registry for managing available commands public actor CommandRegistry { - private var registeredCommands: [String: any Command.Type] = [:] - private var commandMetadata: [String: CommandMetadata] = [:] + /// Metadata about a command + public struct CommandMetadata: Sendable { + /// The name used to invoke this command on the CLI. + public let commandName: String + /// A short description of what the command does. + public let abstract: String + /// Detailed help text for the command. + public let helpText: String + } - /// Metadata about a command - public struct CommandMetadata: Sendable { - public let commandName: String - public let abstract: String - public let helpText: String - } + /// Shared instance + public static let shared = CommandRegistry() - /// Shared instance - public static let shared = CommandRegistry() + private var registeredCommands: [String: any Command.Type] = [:] + private var commandMetadata: [String: CommandMetadata] = [:] - // Internal initializer for testability - allows tests to create isolated instances - internal init() {} + /// Get all registered command names + public var availableCommands: [String] { + Array(registeredCommands.keys).sorted() + } - /// Register a command type with the registry - public func register(_ commandType: T.Type) { - registeredCommands[T.commandName] = commandType - commandMetadata[T.commandName] = CommandMetadata( - commandName: T.commandName, - abstract: T.abstract, - helpText: T.helpText - ) - } + // Internal initializer for testability - allows tests to create isolated instances + internal init() {} - /// Get all registered command names - public var availableCommands: [String] { - Array(registeredCommands.keys).sorted() - } + // MARK: - Public Methods - /// Get command metadata - public func metadata(for name: String) -> CommandMetadata? { - commandMetadata[name] - } + /// Register a command type with the registry + public func register(_ commandType: T.Type) { + registeredCommands[T.commandName] = commandType + commandMetadata[T.commandName] = CommandMetadata( + commandName: T.commandName, + abstract: T.abstract, + helpText: T.helpText + ) + } - /// Get command type for the given name - public func commandType(named name: String) -> (any Command.Type)? { - return registeredCommands[name] - } + /// Get command metadata + public func metadata(for name: String) -> CommandMetadata? { + commandMetadata[name] + } - /// Create a command instance dynamically with automatic config parsing - public func createCommand(named name: String) async throws -> any Command { - guard let commandType = registeredCommands[name] else { - throw CommandRegistryError.unknownCommand(name) - } + /// Get command type for the given name + public func commandType(named name: String) -> (any Command.Type)? { + registeredCommands[name] + } - return try await commandType.createInstance() + /// Create a command instance dynamically with automatic config parsing + public func createCommand(named name: String) async throws -> any Command { + guard let commandType = registeredCommands[name] else { + throw CommandRegistryError.unknownCommand(name) } - /// Check if a command is registered - public func isRegistered(_ name: String) -> Bool { - return registeredCommands[name] != nil - } + return try await commandType.createInstance() + } + + /// Check if a command is registered + public func isRegistered(_ name: String) -> Bool { + registeredCommands[name] != nil + } } diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistryError.swift b/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistryError.swift index 84e7848e..51b221cb 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistryError.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/CommandRegistryError.swift @@ -1,6 +1,6 @@ // // CommandRegistryError.swift -// ConfigKeyKit +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -31,12 +31,13 @@ public import Foundation /// Errors that can occur in command registry operations public enum CommandRegistryError: Error, LocalizedError { - case unknownCommand(String) - - public var errorDescription: String? { - switch self { - case .unknownCommand(let name): - return "Unknown command: \(name)" - } + case unknownCommand(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .unknownCommand(let name): + return "Unknown command: \(name)" } -} \ No newline at end of file + } +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Bool.swift b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Bool.swift index afb6819e..d155f087 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Bool.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Bool.swift @@ -69,4 +69,4 @@ extension ConfigKey where Value == Bool { } } -// Application-specific boolean key helpers should be added in application code \ No newline at end of file +// Application-specific boolean key helpers should be added in application code diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Debug.swift b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Debug.swift index 3e101ab8..f0ab9191 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Debug.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey+Debug.swift @@ -28,9 +28,10 @@ // extension ConfigKey: CustomDebugStringConvertible { + /// A textual representation of this key suitable for debugging. public var debugDescription: String { let cliKey = key(for: .commandLine) ?? "nil" let envKey = key(for: .environment) ?? "nil" return "ConfigKey(cli: \(cliKey), env: \(envKey), default: \(defaultValue))" } -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey.swift b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey.swift index 8d43e7c5..4d2a40fd 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKey.swift @@ -49,6 +49,7 @@ public struct ConfigKey: ConfigurationKey, Sendable { internal let baseKey: String? internal let styles: [ConfigKeySource: any NamingStyle] internal let explicitKeys: [ConfigKeySource: String] + /// The default value returned when no source provides a value. public let defaultValue: Value // Non-optional! /// The base key string used for this configuration key @@ -96,6 +97,7 @@ public struct ConfigKey: ConfigurationKey, Sendable { self.defaultValue = defaultVal } + /// Returns the resolved key string for the given source. public func key(for source: ConfigKeySource) -> String? { // Check for explicit key first if let explicit = explicitKeys[source] { @@ -110,4 +112,3 @@ public struct ConfigKey: ConfigurationKey, Sendable { return style.transform(base) } } - diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKeySource.swift b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKeySource.swift index 96a928b0..1adb946f 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKeySource.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigKeySource.swift @@ -27,8 +27,6 @@ // OTHER DEALINGS IN THE SOFTWARE. // -// MARK: - Configuration Key Source - /// Source for configuration keys (CLI arguments or environment variables) public enum ConfigKeySource: CaseIterable, Sendable { /// Command-line arguments (e.g., --cloudkit-container-id) @@ -36,4 +34,4 @@ public enum ConfigKeySource: CaseIterable, Sendable { /// Environment variables (e.g., CLOUDKIT_CONTAINER_ID) case environment -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigurationParseable.swift b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigurationParseable.swift index 0ed4d0a0..fcaab74d 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/ConfigurationParseable.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/ConfigurationParseable.swift @@ -1,6 +1,6 @@ // // ConfigurationParseable.swift -// ConfigKeyKit +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -29,26 +29,28 @@ import Foundation -/// Protocol for configuration types that can parse themselves from command line arguments and environment variables +/// Protocol for configuration types that can parse themselves +/// from command line arguments and environment variables. public protocol ConfigurationParseable: Sendable { - /// Associated type for the configuration reader - associatedtype ConfigReader: Sendable + /// Associated type for the configuration reader + associatedtype ConfigReader: Sendable - /// Associated type for the parent configuration - /// Use `Never` for root configurations that have no parent - associatedtype BaseConfig: Sendable + /// Associated type for the parent configuration + /// Use `Never` for root configurations that have no parent + associatedtype BaseConfig: Sendable - /// Initialize the configuration by parsing from available sources (CLI args, environment variables, defaults) - /// - Parameters: - /// - configuration: The configuration reader to parse values from - /// - base: Optional parent configuration (nil for root configs) - init(configuration: ConfigReader, base: BaseConfig?) async throws + /// Initialize the configuration by parsing from available sources. + /// - Parameters: + /// - configuration: The configuration reader to parse values from. + /// - base: Optional parent configuration (nil for root configs). + /// - Throws: An error if parsing fails. + init(configuration: ConfigReader, base: BaseConfig?) async throws } /// Extension for root configurations (where BaseConfig == Never) -public extension ConfigurationParseable where BaseConfig == Never { - /// Convenience initializer for root configs that don't need a parent - init(configuration: ConfigReader) async throws { - try await self.init(configuration: configuration, base: nil) - } -} \ No newline at end of file +extension ConfigurationParseable where BaseConfig == Never { + /// Convenience initializer for root configs that don't need a parent + public init(configuration: ConfigReader) async throws { + try await self.init(configuration: configuration, base: nil) + } +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/NamingStyle.swift b/Examples/MistDemo/Sources/ConfigKeyKit/NamingStyle.swift index f9982f46..bb72ddd8 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/NamingStyle.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/NamingStyle.swift @@ -27,12 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -// MARK: - Naming Style - /// Protocol for transforming base key strings into different naming conventions public protocol NamingStyle: Sendable { /// Transform a base key string according to this naming style /// - Parameter base: Base key string (e.g., "cloudkit.container_id") /// - Returns: Transformed key string func transform(_ base: String) -> String -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey+Debug.swift b/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey+Debug.swift index e9e0dabe..a15a0079 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey+Debug.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey+Debug.swift @@ -28,9 +28,10 @@ // extension OptionalConfigKey: CustomDebugStringConvertible { + /// A textual representation of this key suitable for debugging. public var debugDescription: String { let cliKey = key(for: .commandLine) ?? "nil" let envKey = key(for: .environment) ?? "nil" return "OptionalConfigKey(cli: \(cliKey), env: \(envKey))" } -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey.swift b/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey.swift index 24cfada5..1525d6ec 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/OptionalConfigKey.swift @@ -86,6 +86,7 @@ public struct OptionalConfigKey: ConfigurationKey, Sendable { self.explicitKeys = [:] } + /// Returns the resolved key string for the given source. public func key(for source: ConfigKeySource) -> String? { // Check for explicit key first if let explicit = explicitKeys[source] { @@ -100,4 +101,3 @@ public struct OptionalConfigKey: ConfigurationKey, Sendable { return style.transform(base) } } - diff --git a/Examples/MistDemo/Sources/ConfigKeyKit/StandardNamingStyle.swift b/Examples/MistDemo/Sources/ConfigKeyKit/StandardNamingStyle.swift index 82cb32b5..3f38b542 100644 --- a/Examples/MistDemo/Sources/ConfigKeyKit/StandardNamingStyle.swift +++ b/Examples/MistDemo/Sources/ConfigKeyKit/StandardNamingStyle.swift @@ -37,6 +37,7 @@ public enum StandardNamingStyle: NamingStyle, Sendable { /// Screaming snake case with prefix (e.g., "APP_CLOUDKIT_CONTAINER_ID") case screamingSnakeCase(prefix: String?) + /// Transform the base key string into the appropriate naming style. public func transform(_ base: String) -> String { switch self { case .dotSeparated: @@ -50,4 +51,4 @@ public enum StandardNamingStyle: NamingStyle, Sendable { return snakeCase } } -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/MistDemo/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemo/CloudKit/MistKitClientFactory.swift deleted file mode 100644 index a77fbbff..00000000 --- a/Examples/MistDemo/Sources/MistDemo/CloudKit/MistKitClientFactory.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// MistKitClientFactory.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit - -/// Factory for creating MistKit CloudKitService instances from MistDemo configuration -public struct MistKitClientFactory: Sendable { - - /// Create a CloudKitService for the given database, choosing auth method automatically. - /// - /// - `.public`: requires `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY[_FILE]` - /// - `.private` / `.shared`: requires `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` - /// - Parameters: - /// - database: Target database - /// - config: The base MistDemo configuration - /// - Throws: ConfigurationError if required credentials are missing - public static func create(_ database: MistKit.Database, from config: MistDemoConfig) throws -> CloudKitService { - let tokenManager: any TokenManager - switch database { - case .public: - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - throw ConfigurationError.unsupportedPlatform( - "Public database access requires macOS 11.0+, iOS 14.0+, tvOS 14.0+, or watchOS 7.0+" - ) - } - tokenManager = try ServerToServerAuthManager(from: config) - case .private, .shared: - tokenManager = try WebAuthTokenManager(from: config) - } - return try CloudKitService( - containerIdentifier: config.containerIdentifier, - tokenManager: tokenManager, - environment: config.environment, - database: database - ) - } - - public static func create( - from config: MistDemoConfig, - tokenManager: any TokenManager, - database: MistKit.Database = .private - ) throws -> CloudKitService { - return try CloudKitService( - containerIdentifier: config.containerIdentifier, - tokenManager: tokenManager, - environment: config.environment, - database: database - ) - } -} - -extension WebAuthTokenManager { - fileprivate convenience init(from config: MistDemoConfig) throws { - let apiToken = AuthenticationHelper.resolveAPIToken(config.apiToken) - guard !apiToken.isEmpty else { - throw ConfigurationError.missingRequired("api.token", - suggestion: "Provide via CLOUDKIT_API_TOKEN environment variable") - } - let webAuthToken = config.webAuthToken.flatMap { AuthenticationHelper.resolveWebAuthToken($0) } - guard let webAuthToken else { - throw ConfigurationError.missingRequired("web.auth.token", - suggestion: "Provide via CLOUDKIT_WEB_AUTH_TOKEN or run `mistdemo auth-token`") - } - self.init(apiToken: apiToken, webAuthToken: webAuthToken) - } -} - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension ServerToServerAuthManager { - fileprivate convenience init(from config: MistDemoConfig) throws { - guard let keyID = config.keyID, !keyID.isEmpty else { - throw ConfigurationError.missingRequired("key.id", - suggestion: "Provide via CLOUDKIT_KEY_ID environment variable") - } - guard let rawKey = config.privateKey ?? Self.loadPrivateKeyFromFile(config.privateKeyFile), - !rawKey.isEmpty else { - throw ConfigurationError.missingRequired("private.key", - suggestion: "Provide via CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH") - } - try self.init(keyID: keyID, pemString: rawKey.replacingOccurrences(of: "\\n", with: "\n")) - } - - private static func loadPrivateKeyFromFile(_ filePath: String?) -> String? { - guard let filePath, !filePath.isEmpty else { return nil } - return try? String(contentsOfFile: filePath, encoding: .utf8) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/AuthTokenCommand.swift deleted file mode 100644 index 72c0f0b3..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/AuthTokenCommand.swift +++ /dev/null @@ -1,233 +0,0 @@ -// -// AuthTokenCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import HTTPTypes -import Hummingbird -import Logging -import MistKit - -/// Command to obtain web authentication token via browser flow -public struct AuthTokenCommand: MistDemoCommand { - public typealias Config = AuthTokenConfig - public static let commandName = "auth-token" - public static let abstract = "Obtain a web authentication token via browser flow" - public static let helpText = """ - AUTH-TOKEN - Obtain web authentication token - - USAGE: - mistdemo auth-token [options] - - OPTIONS: - --api-token CloudKit API token (or CLOUDKIT_API_TOKEN env) - --port Server port (default: 8080) - --host Server host (default: 127.0.0.1) - --no-browser Don't open browser automatically - """ - - private let config: AuthTokenConfig - - private struct CloudKitClientConfig: Encodable { - let apiToken: String - let containerIdentifier: String - } - - public init(config: AuthTokenConfig) { - self.config = config - } - - public func execute() async throws { - print("🚀 Starting CloudKit Authentication Server") - print("📍 Server URL: http://\(config.host):\(config.port)") - print("🔑 API Token: \(config.apiToken.maskedAPIToken)") - - let tokenChannel = AsyncChannel() - let responseCompleteChannel = AsyncChannel() - - let router = Router(context: BasicRequestContext.self) - router.middlewares.add(LogRequestsMiddleware(.info)) - - // Find and serve static resources (index.html) - let resourcesPath = try findResourcesPath() - print("📁 Serving static files from: \(resourcesPath)") - - router.middlewares.add( - FileMiddleware( - resourcesPath, - searchForIndexHtml: true - ) - ) - - // API endpoint for authentication callback - let api = router.group("api") - - let configPayload = CloudKitClientConfig( - apiToken: config.apiToken, - containerIdentifier: config.containerIdentifier - ) - let configData = try JSONEncoder().encode(configPayload) - - api.get("config") { request, _ -> Response in - // Restrict to loopback destinations. The Host header reflects the request's - // destination host (not the origin), so this prevents requests to non-loopback - // addresses but does not block cross-origin browser requests. For full CORS - // protection, check the Origin header (set by browsers and not JS-spoofable). - let host = request.headers[HTTPField.Name("Host")!] ?? "" - guard host.hasPrefix("localhost") || host.hasPrefix("127.0.0.1") else { - return Response(status: .forbidden) - } - return Response( - status: .ok, - headers: [.contentType: "application/json"], - body: ResponseBody { writer in - try await writer.write(ByteBuffer(bytes: configData)) - try await writer.finish(nil) - } - ) - } - - api.post("authenticate") { request, context -> Response in - let authRequest = try await request.decode(as: AuthRequest.self, context: context) - await tokenChannel.send(authRequest.sessionToken) - - // Validate the received token quickly - let response = AuthResponse( - userRecordName: authRequest.userRecordName, - cloudKitData: .init(user: nil, zones: [], error: nil), - message: "Authentication successful! Token received." - ) - - let jsonData = try JSONEncoder().encode(response) - - // Signal completion after a brief delay - Task { - try await Task.sleep(nanoseconds: 200_000_000) - await responseCompleteChannel.send(()) - } - - return Response( - status: .ok, - headers: [.contentType: "application/json"], - body: ResponseBody { writer in - try await writer.write(ByteBuffer(bytes: jsonData)) - try await writer.finish(nil) - } - ) - } - - // Start the HTTP server - let app = Application( - router: router, - configuration: .init( - address: .hostname(config.host, port: config.port) - ) - ) - - let serverTask = Task { - try await app.runService() - } - - // Open browser unless disabled - if !config.noBrowser { - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 second - print("🌐 Opening browser...") - BrowserOpener.openBrowser(url: "http://\(config.host):\(config.port)") - } - } else { - print("ℹ️ Browser opening disabled. Navigate to http://\(config.host):\(config.port) manually") - } - - print("⏳ Waiting for authentication...") - print(" Timeout: 5 minutes") - print(" Press Ctrl+C to cancel") - - let token: String - do { - token = try await withTimeoutAndSignals(seconds: 300) { - await tokenChannel.receive() - } - } catch let error as AsyncTimeoutError { - serverTask.cancel() - throw AuthTokenError.timeout(error.localizedDescription) - } catch { - serverTask.cancel() - throw error - } - - print("✅ Authentication successful! Received token.") - - // Wait for response completion - await responseCompleteChannel.receive() - - // Shutdown server - serverTask.cancel() - try await Task.sleep(nanoseconds: 500_000_000) - - // Output token to stdout (this is the main output of the command) - print(token) - } - - /// Find the resources directory containing index.html - private func findResourcesPath() throws -> String { - let possiblePaths = [ - Bundle.main.resourcePath ?? "", - Bundle.main.bundlePath + "/Contents/Resources", - "./Sources/MistDemo/Resources", - "./Examples/MistDemo/Sources/MistDemo/Resources", - URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources").path - ] - - for path in possiblePaths { - if !path.isEmpty && FileManager.default.fileExists(atPath: path + "/index.html") { - return path - } - } - - throw AuthTokenError.missingResource("index.html not found in any expected location") - } -} - -/// Authentication-related errors for auth-token command -public enum AuthTokenError: Error, LocalizedError { - case timeout(String) - case missingResource(String) - case serverError(String) - - public var errorDescription: String? { - switch self { - case .timeout(let message): - return "Authentication timeout: \(message)" - case .missingResource(let resource): - return "Missing resource: \(resource)" - case .serverError(let message): - return "Server error: \(message)" - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/CreateCommand.swift deleted file mode 100644 index 1278562f..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/CreateCommand.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// CreateCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import MistKit - -/// Command to create a new record in CloudKit -public struct CreateCommand: MistDemoCommand, OutputFormatting { - public typealias Config = CreateConfig - public static let commandName = "create" - public static let abstract = "Create a new record in CloudKit" - public static let helpText = """ - CREATE - Create a new record in CloudKit - - USAGE: - mistdemo create [options] - - REQUIRED: - --api-token CloudKit API token - --web-auth-token Web authentication token - - OPTIONS: - --record-type Record type to create (default: Note) - --zone Zone name (default: _defaultZone) - --record-name Custom record name (auto-generated if omitted) - --output-format Output format: json, table, csv, yaml - - FIELD DEFINITION (choose one method): - --field Inline field definition - --json-file Load fields from JSON file - --stdin Read fields from stdin as JSON - - FIELD FORMAT: - Format: name:type:value - Multiple fields: separate with commas - - FIELD TYPES: - string Text values - int64 Integer numbers - double Decimal numbers - timestamp Dates (ISO 8601 or Unix timestamp) - asset Asset URL (from upload-asset command) - - EXAMPLES: - - 1. Single field: - mistdemo create --field "title:string:My Note" - - 2. Multiple fields (comma-separated): - mistdemo create --field "title:string:My Note, priority:int64:5" - - 3. With timestamp: - mistdemo create --field "title:string:Task, due:timestamp:2026-02-01T09:00:00Z" - - 4. From JSON file: - mistdemo create --json-file fields.json - - Example fields.json: - { - "title": "Project Plan", - "priority": 8, - "progress": 0.35 - } - - 5. From stdin: - echo '{"title":"Quick Note"}' | mistdemo create --stdin - - 6. Table output format: - mistdemo create --field "title:string:Test" --output-format table - - 7. With asset (after upload-asset): - mistdemo create --field "title:string:My Photo, image:asset:https://cws.icloud-content.com:443/..." - - NOTES: - • Record name is auto-generated if not provided - • JSON files auto-detect field types from values - • Use environment variables CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN - to avoid repeating tokens - """ - - private let config: CreateConfig - - public init(config: CreateConfig) { - self.config = config - } - - public func execute() async throws { - do { - // Create CloudKit client - let client = try MistKitClientFactory.create(.private, from: config.base) - - // Generate record name if not provided - let recordName = config.recordName ?? generateRecordName() - - // Convert fields to CloudKit format - let cloudKitFields = try config.fields.toCloudKitFields() - - // Create the record - // NOTE: Zone support requires enhancements to CloudKitService.createRecord method - let recordInfo = try await client.createRecord( - recordType: config.recordType, - recordName: recordName, - fields: cloudKitFields - // Zone: config.zone - to be added when CloudKitService supports it - ) - - // Format and output result - try await outputResult(recordInfo, format: config.output) - - } catch { - throw CreateError.operationFailed(error.localizedDescription) - } - } - - /// Generate a unique record name - private func generateRecordName() -> String { - let timestamp = Int(Date().timeIntervalSince1970) - let randomSuffix = String(Int.random(in: MistDemoConstants.Limits.randomSuffixMin...MistDemoConstants.Limits.randomSuffixMax)) - return "\(config.recordType.lowercased())-\(timestamp)-\(randomSuffix)" - } -} - -// CreateError is now defined in Errors/CreateError.swift \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/CurrentUserCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/CurrentUserCommand.swift deleted file mode 100644 index 16f360cc..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/CurrentUserCommand.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// CurrentUserCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Command to get information about the authenticated user -public struct CurrentUserCommand: MistDemoCommand, OutputFormatting { - public typealias Config = CurrentUserConfig - public static let commandName = "current-user" - public static let abstract = "Get current user information" - public static let helpText = """ - CURRENT-USER - Get current user information - - USAGE: - mistdemo current-user [options] - - OPTIONS: - --api-token CloudKit API token - --web-auth-token Web authentication token - --fields Comma-separated list of fields to include - --output-format Output format: json, table, csv, yaml - """ - - private let config: CurrentUserConfig - - public init(config: CurrentUserConfig) { - self.config = config - } - - public func execute() async throws { - do { - // Create CloudKit client - let client = try MistKitClientFactory.create(.private, from: config.base) - - // Fetch current user information - let userInfo = try await client.fetchCurrentUser() - - // Filter fields if requested - let filteredUser = filterUserFields(userInfo, fields: config.fields) - - // Format and output result - try await outputResult(filteredUser, format: config.output) - - } catch { - throw CurrentUserError.operationFailed(error.localizedDescription) - } - } - - /// Filter user fields based on requested fields - /// Since UserInfo constructor is internal, we work with the original object - /// and filter during output instead - private func filterUserFields(_ userInfo: UserInfo, fields: [String]?) -> UserInfo { - // Since we can't create new UserInfo instances, return the original - // Field filtering will be handled in the output methods - return userInfo - } - - /// Check if a field should be included in output based on field filters - private func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { - guard let fields = fields, !fields.isEmpty else { - return true // Include all fields if no filter specified - } - - let normalizedFieldName = fieldName.lowercased() - return fields.contains { requestedField in - requestedField.lowercased() == normalizedFieldName - } - } -} - -// CurrentUserError is now defined in Errors/CurrentUserError.swift \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/DemoInFilterCommand.swift deleted file mode 100644 index 97721bd0..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/DemoInFilterCommand.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// DemoInFilterCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Demonstrates the IN/NOT_IN QueryFilter fix (issue #192) end-to-end. -/// -/// The command: -/// 1. Creates three Note records with index values 10, 20, 30 -/// 2. Queries them back using QueryFilter.in("index", [10, 30]) -/// — expects exactly 2 results, confirming type-preserving serialization works -/// 3. Cleans up all three created records -public struct DemoInFilterCommand: MistDemoCommand { - public typealias Config = MistDemoConfig - public static let commandName = "demo-in-filter" - public static let abstract = "Demonstrates IN/NOT_IN QueryFilter (issue #192) against CloudKit" - public static let helpText = """ - DEMO-IN-FILTER - Demonstrate IN/NOT_IN QueryFilter fix (issue #192) - - USAGE: - mistdemo demo-in-filter [options] - - REQUIRED: - --api-token CloudKit API token - --web-auth-token Web authentication token - - DESCRIPTION: - Creates three Note records with index values 10, 20, and 30, - then queries them back using an IN filter for [10, 30]. - Expects 2 results, confirming that type information is preserved - in the serialized filter payload (the fix for issue #192). - Created records are deleted after the demo completes. - """ - - private let config: MistDemoConfig - - public init(config: MistDemoConfig) { - self.config = config - } - - public func execute() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - print("demo-in-filter requires macOS 11+ / iOS 14+") - return - } - - let client = try MistKitClientFactory.create(.public, from: config) - let tag = Int(Date().timeIntervalSince1970) - let recordType = "Note" - - // Step 1 — create three records with index values 10, 20, 30 - print("Creating 3 Note records with index values 10, 20, 30…") - let indexValues: [Int] = [10, 20, 30] - var createdNames: [String] = [] - for idx in indexValues { - let record = try await client.createRecord( - recordType: recordType, - fields: [ - "title": .string("demo-in-filter-\(tag)-idx\(idx)"), - "index": .int64(idx) - ] - ) - createdNames.append(record.recordName) - print(" Created \(record.recordName) (index=\(idx))") - } - - // Diagnostic: query without filter to verify records are immediately visible - print("\nVerifying records are queryable (no filter)…") - let allRecords = try await client.queryRecords(recordType: recordType, limit: 200) - let allDemoRecords = allRecords.filter { createdNames.contains($0.recordName) } - print(" Total Note records: \(allRecords.count), demo records visible: \(allDemoRecords.count)") - if allDemoRecords.count < 3 { - print(" Waiting 2s for CloudKit indexing…") - try await Task.sleep(nanoseconds: 2_000_000_000) - } - - // Step 2 — query back records where index IN [10, 30] - print("\nQuerying with IN filter for index values [10, 30]…") - let results = try await client.queryRecords( - recordType: recordType, - filters: [.in("index", [.int64(10), .int64(30)])], - limit: 200 - ) - - let matching = results.filter { createdNames.contains($0.recordName) } - // The record with index=20 should NOT be in the results (filter should exclude it) - let index20Name = createdNames.count == 3 ? createdNames[1] : nil - let falsePositive = index20Name.map { n in results.contains { $0.recordName == n } } ?? false - print("Total results returned: \(results.count)") - print("Matching demo records: \(matching.count) (expected 2)") - for record in matching { - let idx = record.fields["index"].flatMap { if case .int64(let v) = $0 { return v } else { return nil } } - print(" \(record.recordName) index=\(idx.map(String.init) ?? "?")") - } - - if matching.count == 2 && !falsePositive { - print("\n✓ IN filter works correctly — issue #192 fix confirmed") - } else if falsePositive { - print("\n✗ Filter not working — index=20 record appeared in results (filter ignored)") - } else { - print("\n✗ Unexpected result count — check CloudKit for details") - } - - // Step 3 — clean up (use forceDelete — delete requires recordChangeTag which we don't store) - print("\nDeleting demo records…") - for name in createdNames { - let op = RecordOperation( - operationType: .forceDelete, - recordType: recordType, - recordName: name - ) - _ = try await client.modifyRecords([op]) - print(" Deleted \(name)") - } - print("Done.") - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/FetchChangesCommand.swift deleted file mode 100644 index a5e06e8a..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/FetchChangesCommand.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// FetchChangesCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Command to fetch record changes with incremental sync -public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { - public typealias Config = FetchChangesConfig - public static let commandName = "fetch-changes" - public static let abstract = "Fetch record changes with incremental sync" - public static let helpText = """ - FETCH-CHANGES - Fetch record changes with incremental sync - - USAGE: - mistdemo fetch-changes [options] - - OPTIONS: - --sync-token Sync token from previous fetch - --zone Zone name (default: "_defaultZone") - --fetch-all Fetch all changes with automatic pagination - --limit Maximum results per page (1-200) - --output-format Output format: json, table, csv, yaml - - EXAMPLES: - # Fetch initial changes - mistdemo fetch-changes - - # Fetch with pagination - mistdemo fetch-changes --fetch-all - - # Incremental sync with token - mistdemo fetch-changes --sync-token "previous-token" - - NOTES: - - Save the returned sync token for incremental fetching - - Use --fetch-all to automatically paginate through all changes - """ - - private let config: FetchChangesConfig - - public init(config: FetchChangesConfig) { - self.config = config - } - - public func execute() async throws { - print("\n" + String(repeating: "=", count: 60)) - print("🔄 Fetch Record Changes") - print(String(repeating: "=", count: 60)) - - let service = try MistKitClientFactory.create(.private, from: config.base) - let zoneID = ZoneID(zoneName: config.zone, ownerName: nil) - - if config.fetchAll { - print("\n📦 Fetching all changes (automatic pagination)...") - if let token = config.syncToken { - print(" Using sync token: \(token.prefix(20))...") - } else { - print(" Performing initial fetch (no sync token)") - } - - let (records, newToken) = try await service.fetchAllRecordChanges( - zoneID: zoneID, - syncToken: config.syncToken - ) - print("\n✅ Fetched \(records.count) record(s)") - displayRecords(records, limit: 5) - if let token = newToken { - print("\n💾 New sync token: \(token.prefix(20))...") - print(" Save this token to fetch only new changes next time:") - print(" mistdemo fetch-changes --sync-token '\(token)'") - } - } else { - print("\n📄 Fetching single page...") - if let token = config.syncToken { - print(" Using sync token: \(token.prefix(20))...") - } else { - print(" Performing initial fetch (no sync token)") - } - - let result = try await service.fetchRecordChanges( - zoneID: zoneID, - syncToken: config.syncToken, - resultsLimit: config.limit ?? 10 - ) - print("\n✅ Fetched \(result.records.count) record(s)") - displayRecords(result.records, limit: 5) - - if result.moreComing { - print("\n⚠️ More changes available! Use --sync-token with:") - if let token = result.syncToken { - print(" mistdemo fetch-changes --sync-token '\(token)'") - } - } - - if let token = result.syncToken { - print("\n💾 Sync token: \(token.prefix(20))...") - } - } - - print("\n" + String(repeating: "=", count: 60)) - print("✅ Fetch completed!") - print(String(repeating: "=", count: 60)) - } - - private func displayRecords(_ records: [RecordInfo], limit: Int) { - let displayed = records.prefix(limit) - for record in displayed { - print(" 📝 \(record.recordType) - \(record.recordName)") - if !record.fields.isEmpty { - print(" Fields: \(record.fields.keys.joined(separator: ", "))") - } - } - if records.count > limit { - print(" ... and \(records.count - limit) more") - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/LookupZonesCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/LookupZonesCommand.swift deleted file mode 100644 index 92303e18..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/LookupZonesCommand.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// LookupZonesCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Command to look up specific CloudKit zones by name -public struct LookupZonesCommand: MistDemoCommand, OutputFormatting { - public typealias Config = LookupZonesConfig - public static let commandName = "lookup-zones" - public static let abstract = "Look up specific CloudKit zones by name" - public static let helpText = """ - LOOKUP-ZONES - Look up specific CloudKit zones by name - - USAGE: - mistdemo lookup-zones [options] - - OPTIONS: - --zone-names Comma-separated zone names (default: "_defaultZone") - --output-format Output format: json, table, csv, yaml - - EXAMPLES: - # Look up the default zone - mistdemo lookup-zones - - # Look up specific zones - mistdemo lookup-zones --zone-names "Articles,Photos" - - NOTES: - - Uses web authentication (private database) by default - - Zone names are case-sensitive - """ - - private let config: LookupZonesConfig - - public init(config: LookupZonesConfig) { - self.config = config - } - - public func execute() async throws { - print("\n" + String(repeating: "=", count: 60)) - print("🔍 Lookup CloudKit Zones") - print(String(repeating: "=", count: 60)) - - let service = try MistKitClientFactory.create(.private, from: config.base) - let zoneIDs = config.zoneNames.map { ZoneID(zoneName: $0, ownerName: nil) } - - print("\n📋 Looking up \(zoneIDs.count) zone(s):") - for name in config.zoneNames { - print(" - \(name)") - } - - let zones = try await service.lookupZones(zoneIDs: zoneIDs) - print("\n✅ Found \(zones.count) zone(s):") - for zone in zones { - print(" - \(zone.zoneName)") - if let owner = zone.ownerRecordName { - print(" Owner: \(owner)") - } - if !zone.capabilities.isEmpty { - print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") - } - } - - print("\n" + String(repeating: "=", count: 60)) - print("✅ Lookup completed!") - print(String(repeating: "=", count: 60)) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/QueryCommand.swift deleted file mode 100644 index b9639f61..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/QueryCommand.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// QueryCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit - -/// Command to query Note records from CloudKit with filtering and sorting -public struct QueryCommand: MistDemoCommand, OutputFormatting { - public typealias Config = QueryConfig - public static let commandName = "query" - public static let abstract = "Query records from CloudKit with filtering and sorting" - public static let helpText = """ - QUERY - Query records from CloudKit - - USAGE: - mistdemo query [options] - - OPTIONS: - --record-type Record type to query (default: Note) - --zone Zone name (default: _defaultZone) - --filter Filter expression(s) (field:operator:value, use | to separate multiple) - --sort Sort by field (order: asc/desc) - --limit Maximum records to return (1-200) - --fields Comma-separated fields to include - --output-format Output format: json, table, csv, yaml - """ - - private let config: QueryConfig - - public init(config: QueryConfig) { - self.config = config - } - - public func execute() async throws { - do { - // Create CloudKit client - let client = try MistKitClientFactory.create(.public, from: config.base) - - // Build filters - // NOTE: Zone, offset, and continuation marker support require - // enhancements to CloudKitService.queryRecords method (GitHub issues #145, #146) - let recordInfos: [RecordInfo] - if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { - let filters: [QueryFilter]? = config.filters.isEmpty - ? nil - : try config.filters.map { try parseFilter($0) } - recordInfos = try await client.queryRecords( - recordType: config.recordType, - filters: filters, - sortBy: nil, - limit: config.limit - ) - } else { - recordInfos = try await client.queryRecords( - recordType: config.recordType, - filters: nil, - sortBy: nil, - limit: config.limit - ) - } - - // Format and output results - try await outputResults(recordInfos, format: config.output) - - } catch { - throw QueryError.operationFailed(error.localizedDescription) - } - } - - /// Parse a single filter expression "field:operator:value" into a QueryFilter - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - private func parseFilter(_ filterString: String) throws -> QueryFilter { - let components = filterString.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) - - guard components.count == 3 else { - throw QueryError.invalidFilter(filterString, expected: "field:operator:value") - } - - let field = String(components[0]).trimmingCharacters(in: .whitespaces) - let operatorString = String(components[1]).trimmingCharacters(in: .whitespaces) - let value = String(components[2]) - - guard !field.isEmpty else { - throw QueryError.emptyFieldName(filterString) - } - - switch operatorString.lowercased() { - case "eq", "equals", "==", "=": - return .equals(field, inferFieldValue(value)) - case "ne", "not_equals", "!=": - return .notEquals(field, inferFieldValue(value)) - case "gt", ">": - return .greaterThan(field, inferFieldValue(value)) - case "gte", ">=": - return .greaterThanOrEquals(field, inferFieldValue(value)) - case "lt", "<": - return .lessThan(field, inferFieldValue(value)) - case "lte", "<=": - return .lessThanOrEquals(field, inferFieldValue(value)) - case "contains", "like": - return .containsAllTokens(field, value) - case "begins_with", "starts_with": - return .beginsWith(field, value) - case "in": - let values = value.split(separator: ",").map { inferFieldValue(String($0)) } - return .in(field, values) - case "not_in": - let values = value.split(separator: ",").map { inferFieldValue(String($0)) } - return .notIn(field, values) - default: - throw QueryError.unsupportedOperator(operatorString) - } - } - - /// Infer a FieldValue from a string, preferring Int64, then Double, then String - private func inferFieldValue(_ string: String) -> FieldValue { - if let i = Int64(string) { return .int64(Int(i)) } - if let d = Double(string) { return .double(d) } - return .string(string) - } - - /// Check if a field should be included based on field filter - private func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { - guard let fields = fields, !fields.isEmpty else { - return true // Include all fields if no filter specified - } - - return fields.contains { requestedField in - fieldName.lowercased() == requestedField.lowercased() - } - } -} - -// QueryError is now defined in Errors/QueryError.swift \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/TestIntegrationCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/TestIntegrationCommand.swift deleted file mode 100644 index 7f8557a9..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/TestIntegrationCommand.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// TestIntegrationCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Command to run comprehensive integration tests for all CloudKit operations -public struct TestIntegrationCommand: MistDemoCommand { - public typealias Config = TestIntegrationConfig - public static let commandName = "test-integration" - public static let abstract = "Run comprehensive integration tests for all CloudKit operations" - public static let helpText = """ - TEST-INTEGRATION - Run comprehensive integration tests (public database) - - Tests all non-user-scoped CloudKit API methods against the public database. - Use 'test-private' to also test user-identity APIs (requires web auth token). - - USAGE: - mistdemo test-integration [options] - - OPTIONS: - --database Database to use: public, private (default: "public") - --record-count Number of test records to create (default: 10) - --asset-size Asset size for test in KB (default: 100) - --skip-cleanup Skip cleanup after integration test - --verbose Run in verbose mode - - PHASES: - 1. List all zones (listZones) - 2. Lookup default zone (lookupZones) - 3. Upload test asset (uploadAssets) - 4. Create records (createRecord) - 5. Query records by type (queryRecords) - 6. Lookup records by name (lookupRecords) - 7. Initial sync (fetchRecordChanges) - 8. Modify records (updateRecord) - 9. Incremental sync (fetchRecordChanges with token) - 10. Final zone check (lookupZones) - 11. Cleanup (deleteRecord) - - EXAMPLES: - # Run with server-to-server auth (public database) - mistdemo test-integration --verbose - - # Run without cleanup for debugging - mistdemo test-integration --skip-cleanup --verbose - - NOTES: - - Requires CLOUDKIT_KEY_ID and CLOUDKIT_PRIVATE_KEY for public database - - For user-identity API coverage, use 'mistdemo test-private' instead - """ - - private let config: TestIntegrationConfig - - public init(config: TestIntegrationConfig) { - self.config = config - } - - public func execute() async throws { - let service: CloudKitService - switch config.database { - case .public: - service = try MistKitClientFactory.create(.public, from: config.base) - case .private: - service = try MistKitClientFactory.create(.private, from: config.base) - case .shared: - service = try MistKitClientFactory.create(.shared, from: config.base) - } - - let runner = IntegrationTestRunner( - service: service, - containerIdentifier: config.base.containerIdentifier, - database: config.database, - recordCount: config.recordCount, - assetSizeKB: config.assetSizeKB, - skipCleanup: config.skipCleanup, - verbose: config.verbose - ) - - try await runner.runBasicWorkflow() - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/TestPrivateCommand.swift deleted file mode 100644 index fb14ea40..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/TestPrivateCommand.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// TestPrivateCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Command to run comprehensive integration tests against the private database, -/// covering all CloudKit API methods including user-identity endpoints. -public struct TestPrivateCommand: MistDemoCommand { - public typealias Config = TestPrivateConfig - public static let commandName = "test-private" - public static let abstract = "Run comprehensive integration tests for private database (all API methods)" - public static let helpText = """ - TEST-PRIVATE - Run comprehensive integration tests (private database) - - Tests all CloudKit API methods including user-identity endpoints that - require private database access and web authentication. - - USAGE: - mistdemo test-private [options] - - OPTIONS: - --record-count Number of test records to create (default: 10) - --asset-size Asset size for test in KB (default: 100) - --skip-cleanup Skip cleanup after integration test - --verbose Run in verbose mode - - PHASES: - 1. List all zones (listZones) - 2. Lookup default zone (lookupZones) - 3. Upload test asset (uploadAssets) - 4. Create records (createRecord) - 5. Query records by type (queryRecords) - 6. Lookup records by name (lookupRecords) - 7. Initial sync (fetchRecordChanges) - 8. Modify records (updateRecord) - 9. Incremental sync (fetchRecordChanges with token) - 10. Final zone check (lookupZones) - 11. Cleanup (deleteRecord) - 12. Fetch current user (fetchCurrentUser) - 13. Discover user identities (discoverUserIdentities) - - EXAMPLES: - # Run all private database tests - mistdemo test-private --verbose - - # Run without cleanup for debugging - mistdemo test-private --skip-cleanup --verbose - - NOTES: - - Requires CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN - - Run 'mistdemo auth-token' to obtain a web auth token - - For public-database-only tests, use 'mistdemo test-integration' - """ - - private let config: TestPrivateConfig - - public init(config: TestPrivateConfig) { - self.config = config - } - - public func execute() async throws { - let service = try MistKitClientFactory.create(.private, from: config.base) - - let runner = IntegrationTestRunner( - service: service, - containerIdentifier: config.base.containerIdentifier, - database: .private, - recordCount: config.recordCount, - assetSizeKB: config.assetSizeKB, - skipCleanup: config.skipCleanup, - verbose: config.verbose - ) - - try await runner.runPrivateWorkflow() - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/UpdateCommand.swift deleted file mode 100644 index 94cb2bee..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/UpdateCommand.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// UpdateCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import MistKit - -/// Command to update an existing record in CloudKit -public struct UpdateCommand: MistDemoCommand, OutputFormatting { - public typealias Config = UpdateConfig - public static let commandName = "update" - public static let abstract = "Update an existing record in CloudKit" - public static let helpText = """ - UPDATE - Update an existing record in CloudKit - - USAGE: - mistdemo update --record-name [options] - - REQUIRED: - --api-token CloudKit API token - --web-auth-token Web authentication token - --record-name Record name to update (REQUIRED) - - OPTIONS: - --record-type Record type (default: Note) - --zone Zone name (default: _defaultZone) - --record-change-tag Change tag for optimistic locking - --output-format Output format: json, table, csv, yaml - - FIELD DEFINITION (choose one method): - --field Inline field definition - --json-file Load fields from JSON file - --stdin Read fields from stdin as JSON - - FIELD FORMAT: - Format: name:type:value - Multiple fields: separate with commas - - FIELD TYPES: - string Text values - int64 Integer numbers - double Decimal numbers - timestamp Dates (ISO 8601 or Unix timestamp) - asset Asset URL (from upload-asset command) - - EXAMPLES: - - 1. Update single field: - mistdemo update --record-name my-note-123 --field "title:string:Updated Title" - - 2. Update multiple fields (comma-separated): - mistdemo update --record-name my-note-123 --field "title:string:New Title, priority:int64:8" - - 3. With optimistic locking: - mistdemo update --record-name my-note-123 \\ - --record-change-tag abc123 --field "title:string:Safe Update" - - 4. From JSON file: - mistdemo update --record-name my-note-123 --json-file updates.json - - Example updates.json: - { - "title": "Updated Project Plan", - "priority": 9, - "progress": 0.75 - } - - 5. From stdin: - echo '{"title":"Quick Update"}' | mistdemo update --record-name my-note-123 --stdin - - 6. Table output format: - mistdemo update --record-name my-note-123 --field "title:string:Test" --output-format table - - 7. Update asset field (after upload-asset): - mistdemo update --record-name my-note-123 \\ - --field "image:asset:https://cws.icloud-content.com:443/..." - - NOTES: - • Record name is REQUIRED for updates - • Only specified fields will be updated, others remain unchanged - • Use record-change-tag for safe concurrent updates - • Use environment variables CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN - to avoid repeating tokens - """ - - private let config: UpdateConfig - - public init(config: UpdateConfig) { - self.config = config - } - - public func execute() async throws { - do { - // Create CloudKit client - let client = try MistKitClientFactory.create(.private, from: config.base) - - // Convert fields to CloudKit format - let cloudKitFields = try config.fields.toCloudKitFields() - - // Update the record - let recordInfo = try await client.updateRecord( - recordType: config.recordType, - recordName: config.recordName, - fields: cloudKitFields, - recordChangeTag: config.recordChangeTag - ) - - // Format and output result - try await outputResult(recordInfo, format: config.output) - - } catch { - throw UpdateError.operationFailed(error.localizedDescription) - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/UploadAssetCommand.swift b/Examples/MistDemo/Sources/MistDemo/Commands/UploadAssetCommand.swift deleted file mode 100644 index fd5db698..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Commands/UploadAssetCommand.swift +++ /dev/null @@ -1,244 +0,0 @@ -// -// UploadAssetCommand.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Command to upload binary assets to CloudKit -public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { - public typealias Config = UploadAssetConfig - public static let commandName = "upload-asset" - public static let abstract = "Upload binary assets to CloudKit" - public static let helpText = """ - UPLOAD-ASSET - Upload binary assets to CloudKit - - USAGE: - mistdemo upload-asset --file [options] - - REQUIRED OPTIONS: - --file Path to the file to upload - - OPTIONAL: - --record-type Record type name (default: "Note") - --field-name Asset field name (default: "image") - --record-name Unique record name (optional, auto-generated if omitted) - --api-token CloudKit API token - --output-format Output format: json, table, csv, yaml - - EXAMPLES: - # Upload with defaults (Note.image) - mistdemo upload-asset --file photo.jpg - - # Upload to custom record type and field - mistdemo upload-asset \\ - --file photo.jpg \\ - --record-type Photo \\ - --field-name thumbnail - - # Upload with specific record name - mistdemo upload-asset \\ - --file document.pdf \\ - --record-type Document \\ - --field-name file \\ - --record-name my-document-123 - - WORKFLOW: - 1. Upload the asset using this command - 2. Note the returned record name and asset details - 3. Use 'create' or 'update' command to associate the asset with a record - - NOTES: - - Maximum file size: 15 MB - - Upload URLs valid for 15 minutes - - With web authentication: uploads to private database - - With API-only authentication: uploads to public database - - Returns asset metadata (receipt, checksums) needed for record operations - - Defaults match MistDemo schema: Note record type, image field - """ - - private let config: UploadAssetConfig - - public init(config: UploadAssetConfig) { - self.config = config - } - - public func execute() async throws { - print("\n" + String(repeating: "=", count: 60)) - print("📤 Upload Asset to CloudKit") - print(String(repeating: "=", count: 60)) - - // Validate file exists - let fileURL = URL(fileURLWithPath: config.file) - guard FileManager.default.fileExists(atPath: config.file) else { - throw UploadAssetError.fileNotFound(config.file) - } - - do { - // Read file data - let data = try Data(contentsOf: fileURL) - let sizeInMB = Double(data.count) / 1024 / 1024 - print("\n📁 File: \(fileURL.lastPathComponent) (\(String(format: "%.2f", sizeInMB)) MB)") - print("📝 Record Type: \(config.recordType)") - print("🏷️ Field Name: \(config.fieldName)") - if let recordName = config.recordName { - print("🆔 Record Name: \(recordName)") - } - - // Check file size (15 MB limit) - let maxSize: Int64 = 15 * 1024 * 1024 - if data.count > maxSize { - throw UploadAssetError.fileTooLarge(Int64(data.count), maximum: maxSize) - } - - let service = try MistKitClientFactory.create(.private, from: config.base) - - // Upload asset - print("\n⬆️ Uploading...") - let result = try await service.uploadAssets( - data: data, - recordType: config.recordType, - fieldName: config.fieldName, - recordName: config.recordName - ) - - print("\n✅ Asset uploaded successfully!") - print(" Record Name: \(result.recordName)") - print(" Field Name: \(result.fieldName)") - if let receipt = result.asset.receipt { - print(" Receipt: \(receipt.prefix(40))...") - } - - // Now create/update the record with the asset - print("\n📝 Creating record with asset...") - do { - let recordInfo = try await createOrUpdateRecordWithAsset( - result: result, - service: service - ) - - if config.recordName != nil { - print("✅ Record updated with asset!") - } else { - print("✅ New record created with asset!") - } - - print(" Record Name: \(recordInfo.recordName)") - print(" Record Type: \(recordInfo.recordType)") - if let changeTag = recordInfo.recordChangeTag { - print(" Change Tag: \(changeTag)") - } - - // Output in requested format - try await outputResult(recordInfo, format: config.output) - - } catch { - print("\n⚠️ Asset uploaded but record operation failed:") - print(" \(error.localizedDescription)") - print("\n The asset is uploaded but not associated with a record.") - print(" Asset details:") - print(" - Record Name: \(result.recordName)") - print(" - Field Name: \(result.fieldName)") - // Don't throw - asset upload succeeded - } - - } catch let error as CloudKitError { - print("\n❌ CloudKit Error: \(error)") - throw UploadAssetError.operationFailed(error.localizedDescription) - } catch let error as UploadAssetError { - print("\n❌ \(error.localizedDescription)") - throw error - } catch { - print("\n❌ Error: \(error)") - throw UploadAssetError.operationFailed(error.localizedDescription) - } - - print("\n" + String(repeating: "=", count: 60)) - print("✅ Upload completed!") - print(String(repeating: "=", count: 60)) - } - - /// Create or update a record with the uploaded asset - /// The asset metadata (receipt, checksums) from CloudKit must be used in the record - private func createOrUpdateRecordWithAsset( - result: AssetUploadReceipt, - service: CloudKitService - ) async throws -> RecordInfo { - // Use the complete asset data from the upload result - // This contains the receipt and checksums returned by CloudKit - var fields: [String: FieldValue] = [ - config.fieldName: .asset(result.asset) - ] - - // Debug: Print asset details - print(" Asset details:") - print(" - Receipt: \(result.asset.receipt ?? "nil")") - print(" - File checksum: \(result.asset.fileChecksum ?? "nil")") - print(" - Size: \(result.asset.size.map(String.init) ?? "nil")") - print(" - Wrapping key: \(result.asset.wrappingKey ?? "nil")") - print(" - Reference checksum: \(result.asset.referenceChecksum ?? "nil")") - - if let recordName = config.recordName { - // User provided recordName → UPDATE existing record's asset field - // First fetch the existing record to get its current recordChangeTag - print(" Fetching existing record to get change tag...") - let existingRecords = try await service.lookupRecords( - recordNames: [recordName] - ) - - guard let existingRecord = existingRecords.first else { - throw UploadAssetError.operationFailed("Record '\(recordName)' not found") - } - - print(" Updating record with change tag: \(existingRecord.recordChangeTag ?? "nil")") - return try await service.updateRecord( - recordType: config.recordType, - recordName: recordName, - fields: fields, - recordChangeTag: existingRecord.recordChangeTag - ) - } else { - // No recordName → CREATE new record with the asset field - // For Note records, add a default title to ensure validity - if config.recordType == "Note" { - fields["title"] = .string("Uploaded Image - \(Date().formatted())") - } - - // Generate a NEW recordName for the record (don't reuse the upload token's recordName) - // The upload recordName is just for the asset upload, not the actual record - let newRecordName = UUID().uuidString.lowercased() - print(" Creating record with new name: \(newRecordName)") - - return try await service.createRecord( - recordType: config.recordType, - recordName: newRecordName, - fields: fields - ) - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/AuthTokenConfig.swift deleted file mode 100644 index 10e724b4..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/AuthTokenConfig.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// AuthTokenConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit -public import ConfigKeyKit - -/// Configuration for auth-token command -public struct AuthTokenConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = Never - - public let apiToken: String - public let containerIdentifier: String - public let port: Int - public let host: String - public let noBrowser: Bool - - public init( - apiToken: String, - // Demo default — override via --container-identifier or config key "container.identifier" - containerIdentifier: String = "iCloud.com.brightdigit.MistDemo", - port: Int = 8080, - host: String = "127.0.0.1", - noBrowser: Bool = false - ) { - self.apiToken = apiToken - self.containerIdentifier = containerIdentifier - self.port = port - self.host = host - self.noBrowser = noBrowser - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: Never? = nil) async throws { - let configReader = configuration - - // Parse command-specific options - let apiToken = configReader.string(forKey: "api.token", isSecret: true) ?? "" - guard !apiToken.isEmpty else { - throw ConfigurationError.missingRequired("api.token", - suggestion: "Provide via --api-token or CLOUDKIT_API_TOKEN environment variable") - } - - // Demo default — override via --container-identifier or config key "container.identifier" - let containerIdentifier = configReader.string( - forKey: "container.identifier", - default: "iCloud.com.brightdigit.MistDemo" - ) ?? "iCloud.com.brightdigit.MistDemo" - let port = configReader.int(forKey: "port", default: 8080) ?? 8080 - let host = configReader.string(forKey: "host", default: "127.0.0.1") ?? "127.0.0.1" - let noBrowser = configReader.bool(forKey: "no.browser", default: false) - - self.init( - apiToken: apiToken, - containerIdentifier: containerIdentifier, - port: port, - host: host, - noBrowser: noBrowser - ) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/ConfigurationError.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/ConfigurationError.swift deleted file mode 100644 index 20270e20..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/ConfigurationError.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// ConfigurationError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Configuration errors -enum ConfigurationError: LocalizedError { - case missingAPIToken - case invalidEnvironment(String) - case invalidDatabase(String) - case missingRequired(String, suggestion: String) - case unsupportedPlatform(String) - - // MARK: Internal - - var errorDescription: String? { - switch self { - case .missingAPIToken: - "CloudKit API token is required. Set CLOUDKIT_API_TOKEN environment variable or use --api-token" - case let .invalidEnvironment(env): - "Invalid environment '\(env)'. Must be 'development' or 'production'" - case let .invalidDatabase(db): - "Invalid database '\(db)'. Must be 'public', 'private', or 'shared'" - case let .missingRequired(field, suggestion): - "Missing required configuration: \(field). \(suggestion)" - case let .unsupportedPlatform(message): - "Unsupported platform: \(message)" - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/CreateConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/CreateConfig.swift deleted file mode 100644 index 1e595878..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/CreateConfig.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// CreateConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit -public import ConfigKeyKit - -/// Configuration for create command -public struct CreateConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let zone: String - public let recordType: String - public let recordName: String? - public let fields: [Field] - public let output: OutputFormat - - public init( - base: MistDemoConfig, - zone: String = "_defaultZone", - recordType: String = "Note", - recordName: String? = nil, - fields: [Field] = [], - output: OutputFormat = .json - ) { - self.base = base - self.zone = zone - self.recordType = recordType - self.recordName = recordName - self.fields = fields - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let configReader = configuration - let baseConfig: MistDemoConfig - if let base = base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - // Parse create-specific options - let zone = configReader.string(forKey: MistDemoConstants.ConfigKeys.zone, default: MistDemoConstants.Defaults.zone) ?? MistDemoConstants.Defaults.zone - let recordType = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordType, default: MistDemoConstants.Defaults.recordType) ?? MistDemoConstants.Defaults.recordType - let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) - - // Parse fields from various sources - let fields = try Self.parseFieldsFromSources(configReader) - - // Parse output format - let outputString = configReader.string(forKey: MistDemoConstants.ConfigKeys.outputFormat, default: MistDemoConstants.Defaults.outputFormat) ?? MistDemoConstants.Defaults.outputFormat - let output = OutputFormat(rawValue: outputString) ?? .json - - self.init( - base: baseConfig, - zone: zone, - recordType: recordType, - recordName: recordName, - fields: fields, - output: output - ) - } - - private static func parseFieldsFromSources(_ configReader: MistDemoConfiguration) throws -> [Field] { - var fields: [Field] = [] - - // 1. Parse inline field definitions - if let fieldString = configReader.string(forKey: "field") { - let fieldDefinitions = fieldString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - let inlineFields = try Field.parseFields(fieldDefinitions) - fields.append(contentsOf: inlineFields) - } - - // 2. Parse from JSON file - if let jsonFile = configReader.string(forKey: MistDemoConstants.ConfigKeys.jsonFile) { - let jsonFields = try parseFieldsFromJSONFile(jsonFile) - fields.append(contentsOf: jsonFields) - } - - // 3. Parse from stdin (check if data is available) - if configReader.bool(forKey: MistDemoConstants.ConfigKeys.stdin, default: false) { - let stdinFields = try parseFieldsFromStdin() - fields.append(contentsOf: stdinFields) - } - - guard !fields.isEmpty else { - throw CreateError.noFieldsProvided - } - - return fields - } - - /// Parse fields from JSON file - private static func parseFieldsFromJSONFile(_ filePath: String) throws -> [Field] { - do { - let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - return try fieldsInput.toFields() - } catch { - throw CreateError.jsonFileError(filePath, error.localizedDescription) - } - } - - /// Parse fields from stdin - private static func parseFieldsFromStdin() throws -> [Field] { - let stdinData = FileHandle.standardInput.readDataToEndOfFile() - - guard !stdinData.isEmpty else { - throw CreateError.emptyStdin - } - - do { - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: stdinData) - return try fieldsInput.toFields() - } catch { - throw CreateError.stdinError(error.localizedDescription) - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/CurrentUserConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/CurrentUserConfig.swift deleted file mode 100644 index 5a39bd85..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/CurrentUserConfig.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// CurrentUserConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit -public import ConfigKeyKit - -/// Configuration for current-user command -public struct CurrentUserConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let fields: [String]? - public let output: OutputFormat - - public init(base: MistDemoConfig, fields: [String]? = nil, output: OutputFormat = .json) { - self.base = base - self.fields = fields - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let configReader = configuration - let baseConfig: MistDemoConfig - if let base = base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - // Parse fields filter - let fieldsString = configReader.string(forKey: "fields") - let fields = fieldsString?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - - // Parse output format - let outputString = configReader.string(forKey: "output.format", default: "json") ?? "json" - let output = OutputFormat(rawValue: outputString) ?? .json - - self.init( - base: baseConfig, - fields: fields, - output: output - ) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/FetchChangesConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/FetchChangesConfig.swift deleted file mode 100644 index b3fd1d59..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/FetchChangesConfig.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// FetchChangesConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import ConfigKeyKit - -/// Configuration for fetch-changes command -public struct FetchChangesConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let syncToken: String? - public let zone: String - public let fetchAll: Bool - public let limit: Int? - public let output: OutputFormat - - public init( - base: MistDemoConfig, - syncToken: String? = nil, - zone: String = "_defaultZone", - fetchAll: Bool = false, - limit: Int? = nil, - output: OutputFormat = .table - ) { - self.base = base - self.syncToken = syncToken - self.zone = zone - self.fetchAll = fetchAll - self.limit = limit - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let baseConfig: MistDemoConfig - if let base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - let syncToken = configuration.string(forKey: "sync.token") - let zone = configuration.string(forKey: "zone", default: "_defaultZone") ?? "_defaultZone" - let fetchAll = configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") - let outputString = configuration.string(forKey: "output.format", default: "table") ?? "table" - let output = OutputFormat(rawValue: outputString) ?? .table - - self.init( - base: baseConfig, - syncToken: syncToken, - zone: zone, - fetchAll: fetchAll, - limit: limit, - output: output - ) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/Field.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/Field.swift deleted file mode 100644 index 6312e2ab..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/Field.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// Field.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Field definition for create operations -public struct Field: Sendable { - public let name: String - public let type: FieldType - public let value: String - - public init(name: String, type: FieldType, value: String) { - self.name = name - self.type = type - self.value = value - } - - /// Parse a field from string format "name:type:value" - /// - Parameter input: String in format "name:type:value" (e.g., "title:string:Hello World") - /// - Throws: FieldParsingError if the format is invalid - public init(parsing input: String) throws { - let components = input.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) - - guard components.count == 3 else { - throw FieldParsingError.invalidFormat(input, expected: "name:type:value") - } - - let name = String(components[0]).trimmingCharacters(in: .whitespaces) - let typeString = String(components[1]).trimmingCharacters(in: .whitespaces) - let value = String(components[2]) // Don't trim value as it may contain meaningful whitespace - - guard !name.isEmpty else { - throw FieldParsingError.emptyFieldName(input) - } - - guard let type = FieldType(rawValue: typeString.lowercased()) else { - throw FieldParsingError.unknownFieldType(typeString, available: FieldType.allCases.map(\.rawValue)) - } - - self.init(name: name, type: type, value: value) - } - - /// Parse multiple fields from an array of strings - /// - Parameter inputs: Array of strings in format "name:type:value" - /// - Returns: Array of parsed Field instances - /// - Throws: FieldParsingError if any field has an invalid format - public static func parseMultiple(_ inputs: [String]) throws -> [Field] { - return try inputs.map { try Field(parsing: $0) } - } - - /// Legacy parse method - delegates to init(parsing:) - /// - Deprecated: Use `init(parsing:)` instead - @available(*, deprecated, renamed: "init(parsing:)", message: "Use Field(parsing:) instead of Field.parse()") - public static func parse(_ input: String) throws -> Field { - return try Field(parsing: input) - } - - /// Legacy parseFields method - delegates to parseMultiple - /// - Deprecated: Use `parseMultiple(_:)` instead - @available(*, deprecated, renamed: "parseMultiple", message: "Use Field.parseMultiple() instead") - public static func parseFields(_ inputs: [String]) throws -> [Field] { - return try parseMultiple(inputs) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/FieldParsingError.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/FieldParsingError.swift deleted file mode 100644 index 620ac696..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/FieldParsingError.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// FieldParsingError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Errors that can occur during field parsing -public enum FieldParsingError: Error, LocalizedError { - case invalidFormat(String, expected: String) - case emptyFieldName(String) - case unknownFieldType(String, available: [String]) - case invalidValueForType(String, type: FieldType) - case unsupportedFieldType(FieldType) - - public var errorDescription: String? { - switch self { - case .invalidFormat(let input, let expected): - return "Invalid field format '\(input)'. Expected format: \(expected)" - case .emptyFieldName(let input): - return "Empty field name in '\(input)'" - case .unknownFieldType(let type, let available): - return "Unknown field type '\(type)'. Available types: \(available.joined(separator: ", "))" - case .invalidValueForType(let value, let type): - return "Invalid value '\(value)' for field type '\(type.rawValue)'" - case .unsupportedFieldType(let type): - return "Field type '\(type.rawValue)' is not yet supported" - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/FieldType.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/FieldType.swift deleted file mode 100644 index 0c3d0b7d..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/FieldType.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// FieldType.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Supported field types for CloudKit records -public enum FieldType: String, CaseIterable, Sendable { - case string - case int64 - case double - case timestamp - case asset - case location - case reference - case bytes - - /// Convert field value to appropriate CloudKit field value - public func convertValue(_ stringValue: String) throws -> Any { - switch self { - case .string: - return stringValue - case .int64: - guard let intValue = Int64(stringValue) else { - throw FieldParsingError.invalidValueForType(stringValue, type: self) - } - return intValue - case .double: - guard let doubleValue = Double(stringValue) else { - throw FieldParsingError.invalidValueForType(stringValue, type: self) - } - return doubleValue - case .timestamp: - // Try parsing as ISO 8601 first, then as timestamp - if let date = ISO8601DateFormatter().date(from: stringValue) { - return date - } else if let timestamp = Double(stringValue) { - return Date(timeIntervalSince1970: timestamp) - } else { - throw FieldParsingError.invalidValueForType(stringValue, type: self) - } - case .asset: - // stringValue should be the URL from the upload token - return stringValue // Will be converted to FieldValue.Asset later - case .location, .reference, .bytes: - // These require more complex parsing - implement later - throw FieldParsingError.unsupportedFieldType(self) - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/LookupZonesConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/LookupZonesConfig.swift deleted file mode 100644 index 29ed50d2..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/LookupZonesConfig.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// LookupZonesConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -public import ConfigKeyKit - -/// Configuration for lookup-zones command -public struct LookupZonesConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let zoneNames: [String] - public let output: OutputFormat - - public init( - base: MistDemoConfig, - zoneNames: [String], - output: OutputFormat = .table - ) { - self.base = base - self.zoneNames = zoneNames - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let baseConfig: MistDemoConfig - if let base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - let zoneNamesString = configuration.string( - forKey: "zone.names", - default: "_defaultZone" - ) ?? "_defaultZone" - let zoneNames = zoneNamesString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } - - let outputString = configuration.string(forKey: "output.format", default: "table") ?? "table" - let output = OutputFormat(rawValue: outputString) ?? .table - - self.init(base: baseConfig, zoneNames: zoneNames, output: output) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfig.swift deleted file mode 100644 index e18103fa..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfig.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// MistDemoConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import ConfigKeyKit -import Configuration -import Foundation -import MistKit - -/// Centralized configuration for MistDemo -/// Implements hierarchical configuration using Swift Configuration (CLI → ENV → defaults) -public struct MistDemoConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = Never - // MARK: - CloudKit Core Configuration - - /// CloudKit container identifier - let containerIdentifier: String - - /// CloudKit API token (secret) - let apiToken: String - - /// CloudKit environment (development or production) - let environment: MistKit.Environment - - // MARK: - Authentication Configuration - - /// Web authentication token (secret) - let webAuthToken: String? - - /// Server-to-server key ID - let keyID: String? - - /// Server-to-server private key (secret) - let privateKey: String? - - /// Path to server-to-server private key file - let privateKeyFile: String? - - // MARK: - Server Configuration - - /// Server host for authentication - let host: String - - /// Server port for authentication - let port: Int - - /// Authentication timeout in seconds (default: 300 = 5 minutes) - let authTimeout: Double - - // MARK: - Test Flags - - /// Skip authentication and use provided token directly - /// @deprecated: Automatic detection based on web-auth-token presence. This flag is ignored. - let skipAuth: Bool - - /// Test all authentication methods - let testAllAuth: Bool - - /// Test API-only authentication - let testApiOnly: Bool - - /// Test AdaptiveTokenManager transitions - let testAdaptive: Bool - - /// Test server-to-server authentication - let testServerToServer: Bool - - // MARK: - Initialization - - /// Initialize with Swift Configuration's hierarchical provider setup - public init(configuration: MistDemoConfiguration, base: Never? = nil) async throws { - let config = configuration - - // CloudKit Core - self.containerIdentifier = config.string( - forKey: "container.identifier", - default: "iCloud.com.brightdigit.MistDemo" - ) ?? "iCloud.com.brightdigit.MistDemo" - - self.apiToken = config.string( - forKey: "api.token", - default: "", - isSecret: true - ) ?? "" - - let envString = config.string( - forKey: "environment", - default: "development" - ) ?? "development" - self.environment = envString == "production" ? .production : .development - - // Authentication - self.webAuthToken = config.string( - forKey: "web.auth.token", - isSecret: true - ) - - self.keyID = config.string( - forKey: "key.id" - ) - - self.privateKey = config.string( - forKey: "private.key", - isSecret: true - ) - - self.privateKeyFile = config.string( - forKey: "private.key.path" - ) - - // Server - self.host = config.string( - forKey: "host", - default: "127.0.0.1" - ) ?? "127.0.0.1" - - self.port = config.int( - forKey: "port", - default: 8080 - ) ?? 8080 - - self.authTimeout = Double(config.int( - forKey: "auth.timeout", - default: 300 - ) ?? 300) - - // Test flags - self.skipAuth = config.bool( - forKey: "skip.auth", - default: false - ) - - self.testAllAuth = config.bool( - forKey: "test.all.auth", - default: false - ) - - self.testApiOnly = config.bool( - forKey: "test.api.only", - default: false - ) - - self.testAdaptive = config.bool( - forKey: "test.adaptive", - default: false - ) - - self.testServerToServer = config.bool( - forKey: "test.server.to.server", - default: false - ) - } -} - diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfiguration.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfiguration.swift deleted file mode 100644 index 80585d18..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfiguration.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// MistDemoConfiguration.swift -// ConfigKeyKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Configuration -import Foundation - -/// Swift Configuration-based setup for MistDemo -public struct MistDemoConfiguration: Sendable { - // MARK: Lifecycle - - public init() throws { - self.configReader = ConfigReader(providers: [ - // 1. Command line arguments (highest priority) - CommandLineArgumentsProvider(), - - // 2. Environment variables (CLOUDKIT_ prefix: e.g. api.token → CLOUDKIT_API_TOKEN) - EnvironmentVariablesProvider().prefixKeys(with: "cloudkit"), - - // 3. In-memory defaults (lowest priority) - InMemoryProvider(values: [ - "port": 8080, - "skip.auth": false, - "test.all.auth": false, - "test.api.only": false, - "test.adaptive": false, - "test.server.to.server": false - ]) - ]) - } - - /// Internal initializer for testing with InMemoryProvider - init(testProvider: InMemoryProvider) { - self.configReader = ConfigReader(providers: [ - testProvider - ]) - } - - // MARK: Private - - private let configReader: ConfigReader - - /// Read string value with hierarchy: CLI → ENV → defaults - public func string( - forKey key: String, - default defaultValue: String? = nil, - isSecret: Bool = false - ) -> String? { - if let defaultValue = defaultValue { - return configReader.string(forKey: Configuration.ConfigKey(key), isSecret: isSecret, default: defaultValue) - } else { - return configReader.string(forKey: Configuration.ConfigKey(key), isSecret: isSecret) - } - } - - /// Read required string value - public func requiredString( - forKey key: String, - isSecret: Bool = false - ) throws -> String { - try configReader.requiredString(forKey: Configuration.ConfigKey(key), isSecret: isSecret) - } - - /// Read int value with hierarchy - public func int( - forKey key: String, - default defaultValue: Int? = nil - ) -> Int? { - if let defaultValue = defaultValue { - return configReader.int(forKey: Configuration.ConfigKey(key), default: defaultValue) - } else { - return configReader.int(forKey: Configuration.ConfigKey(key)) - } - } - - /// Read required int value - public func requiredInt(forKey key: String) throws -> Int { - try configReader.requiredInt(forKey: Configuration.ConfigKey(key)) - } - - /// Read bool value with hierarchy - public func bool( - forKey key: String, - default defaultValue: Bool = false - ) -> Bool { - configReader.bool(forKey: Configuration.ConfigKey(key), default: defaultValue) - } - - /// Read a pipe-separated list of strings from configuration. - /// Splits on "|" and trims whitespace from each element. - public func filterStrings(forKey key: String) -> [String] { - string(forKey: key)? - .split(separator: "|") - .map { String($0).trimmingCharacters(in: .whitespaces) } ?? [] - } - -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/QueryConfig.swift deleted file mode 100644 index c2fa3eb3..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/QueryConfig.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// QueryConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit -public import ConfigKeyKit - -/// Configuration for query command -public struct QueryConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let zone: String - public let recordType: String - public let filters: [String] - public let sort: (field: String, order: SortOrder)? - public let limit: Int - public let offset: Int - public let fields: [String]? - public let continuationMarker: String? - public let output: OutputFormat - - public init( - base: MistDemoConfig, - zone: String = "_defaultZone", - recordType: String = "Note", - filters: [String] = [], - sort: (field: String, order: SortOrder)? = nil, - limit: Int = 20, - offset: Int = 0, - fields: [String]? = nil, - continuationMarker: String? = nil, - output: OutputFormat = .json - ) { - self.base = base - self.zone = zone - self.recordType = recordType - self.filters = filters - self.sort = sort - self.limit = limit - self.offset = offset - self.fields = fields - self.continuationMarker = continuationMarker - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let configReader = configuration - let baseConfig: MistDemoConfig - if let base = base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - // Parse query-specific options - let zone = configReader.string(forKey: MistDemoConstants.ConfigKeys.zone, default: MistDemoConstants.Defaults.zone) ?? MistDemoConstants.Defaults.zone - let recordType = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordType, default: MistDemoConstants.Defaults.recordType) ?? MistDemoConstants.Defaults.recordType - - // Parse filters — multiple filters are separated by "|" so that commas - // remain available as value separators in IN/NOT_IN filter expressions. - // e.g. --filter "index:in:1,2,3|title:eq:Hello" - let filters = configReader.filterStrings(forKey: MistDemoConstants.ConfigKeys.filter) - - // Parse sort option - let sortString = configReader.string(forKey: MistDemoConstants.ConfigKeys.sort) - let sort = try Self.parseSortOption(sortString) - - // Parse limits and pagination - let limit = configReader.int(forKey: MistDemoConstants.ConfigKeys.limit, default: MistDemoConstants.Defaults.queryLimit) ?? MistDemoConstants.Defaults.queryLimit - guard limit >= MistDemoConstants.Limits.minQueryLimit && limit <= MistDemoConstants.Limits.maxQueryLimit else { - throw QueryError.invalidLimit(limit) - } - - let offset = configReader.int(forKey: "offset", default: 0) ?? 0 - - // Parse fields filter - let fieldsString = configReader.string(forKey: MistDemoConstants.ConfigKeys.fields) - let fields = fieldsString?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - - // Parse continuation marker - let continuationMarker = configReader.string(forKey: "continuation.marker") - - // Parse output format - let outputString = configReader.string(forKey: MistDemoConstants.ConfigKeys.outputFormat, default: MistDemoConstants.Defaults.outputFormat) ?? MistDemoConstants.Defaults.outputFormat - let output = OutputFormat(rawValue: outputString) ?? .json - - self.init( - base: baseConfig, - zone: zone, - recordType: recordType, - filters: filters, - sort: sort, - limit: limit, - offset: offset, - fields: Array(fields ?? []), - continuationMarker: continuationMarker, - output: output - ) - } - - private static func parseSortOption(_ sortString: String?) throws -> (field: String, order: SortOrder)? { - guard let sortString = sortString, !sortString.isEmpty else { return nil } - - let components = sortString.split(separator: ":", maxSplits: 1) - guard components.count >= 1 else { return nil } - - let field = String(components[0]).trimmingCharacters(in: .whitespaces) - let orderString = components.count > 1 ? String(components[1]).trimmingCharacters(in: .whitespaces) : "asc" - - guard let order = SortOrder(rawValue: orderString.lowercased()) else { - throw QueryError.invalidSortOrder(orderString, available: SortOrder.allCases.map(\.rawValue)) - } - - return (field: field, order: order) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/TestIntegrationConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/TestIntegrationConfig.swift deleted file mode 100644 index 48a36252..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/TestIntegrationConfig.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// TestIntegrationConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import ConfigKeyKit -public import MistKit - -/// Configuration for test-integration command -public struct TestIntegrationConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let database: MistKit.Database - public let recordCount: Int - public let assetSizeKB: Int - public let skipCleanup: Bool - public let verbose: Bool - - public init( - base: MistDemoConfig, - database: MistKit.Database = .public, - recordCount: Int = 10, - assetSizeKB: Int = 100, - skipCleanup: Bool = false, - verbose: Bool = false - ) { - self.base = base - self.database = database - self.recordCount = recordCount - self.assetSizeKB = assetSizeKB - self.skipCleanup = skipCleanup - self.verbose = verbose - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let baseConfig: MistDemoConfig - if let base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - let databaseString = configuration.string(forKey: "database", default: "public") ?? "public" - guard let database = MistKit.Database(rawValue: databaseString) else { - throw ConfigurationError.invalidDatabase(databaseString) - } - let recordCount = configuration.int(forKey: "record.count", default: 10) ?? 10 - let assetSizeKB = configuration.int(forKey: "asset.size", default: 100) ?? 100 - let skipCleanup = configuration.bool(forKey: "skip.cleanup", default: false) - let verbose = configuration.bool(forKey: "verbose", default: false) - - self.init( - base: baseConfig, - database: database, - recordCount: recordCount, - assetSizeKB: assetSizeKB, - skipCleanup: skipCleanup, - verbose: verbose - ) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/TestPrivateConfig.swift deleted file mode 100644 index 121a719d..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/TestPrivateConfig.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// TestPrivateConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import ConfigKeyKit -public import MistKit - -/// Configuration for test-private command (private database, all API methods) -public struct TestPrivateConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let recordCount: Int - public let assetSizeKB: Int - public let skipCleanup: Bool - public let verbose: Bool - - public init( - base: MistDemoConfig, - recordCount: Int = 10, - assetSizeKB: Int = 100, - skipCleanup: Bool = false, - verbose: Bool = false - ) { - self.base = base - self.recordCount = recordCount - self.assetSizeKB = assetSizeKB - self.skipCleanup = skipCleanup - self.verbose = verbose - } - - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let baseConfig: MistDemoConfig - if let base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - guard let webAuthToken = baseConfig.webAuthToken, !webAuthToken.isEmpty else { - throw ConfigurationError.missingRequired( - "web.auth.token", - suggestion: "Provide via CLOUDKIT_WEB_AUTH_TOKEN or run `mistdemo auth-token`" - ) - } - - let recordCount = configuration.int(forKey: "record.count", default: 10) ?? 10 - let assetSizeKB = configuration.int(forKey: "asset.size", default: 100) ?? 100 - let skipCleanup = configuration.bool(forKey: "skip.cleanup", default: false) - let verbose = configuration.bool(forKey: "verbose", default: false) - - self.init( - base: baseConfig, - recordCount: recordCount, - assetSizeKB: assetSizeKB, - skipCleanup: skipCleanup, - verbose: verbose - ) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/UpdateConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/UpdateConfig.swift deleted file mode 100644 index 29414239..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/UpdateConfig.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// UpdateConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit -public import ConfigKeyKit - -/// Configuration for update command -public struct UpdateConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let zone: String - public let recordType: String - public let recordName: String - public let recordChangeTag: String? - public let fields: [Field] - public let output: OutputFormat - - public init( - base: MistDemoConfig, - zone: String = "_defaultZone", - recordType: String = "Note", - recordName: String, - recordChangeTag: String? = nil, - fields: [Field] = [], - output: OutputFormat = .json - ) { - self.base = base - self.zone = zone - self.recordType = recordType - self.recordName = recordName - self.recordChangeTag = recordChangeTag - self.fields = fields - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let configReader = configuration - let baseConfig: MistDemoConfig - if let base = base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - // Parse update-specific options - let zone = configReader.string(forKey: MistDemoConstants.ConfigKeys.zone, default: MistDemoConstants.Defaults.zone) ?? MistDemoConstants.Defaults.zone - let recordType = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordType, default: MistDemoConstants.Defaults.recordType) ?? MistDemoConstants.Defaults.recordType - - // Validate recordName is provided (REQUIRED for update) - guard let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) else { - throw UpdateError.recordNameRequired - } - - let recordChangeTag = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordChangeTag) - - // Parse fields from various sources - let fields = try Self.parseFieldsFromSources(configReader) - - // Parse output format - let outputString = configReader.string(forKey: MistDemoConstants.ConfigKeys.outputFormat, default: MistDemoConstants.Defaults.outputFormat) ?? MistDemoConstants.Defaults.outputFormat - let output = OutputFormat(rawValue: outputString) ?? .json - - self.init( - base: baseConfig, - zone: zone, - recordType: recordType, - recordName: recordName, - recordChangeTag: recordChangeTag, - fields: fields, - output: output - ) - } - - private static func parseFieldsFromSources(_ configReader: MistDemoConfiguration) throws -> [Field] { - var fields: [Field] = [] - - // 1. Parse inline field definitions - if let fieldString = configReader.string(forKey: "field") { - let fieldDefinitions = fieldString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - let inlineFields = try Field.parseFields(fieldDefinitions) - fields.append(contentsOf: inlineFields) - } - - // 2. Parse from JSON file - if let jsonFile = configReader.string(forKey: MistDemoConstants.ConfigKeys.jsonFile) { - let jsonFields = try parseFieldsFromJSONFile(jsonFile) - fields.append(contentsOf: jsonFields) - } - - // 3. Parse from stdin (check if data is available) - if configReader.bool(forKey: MistDemoConstants.ConfigKeys.stdin, default: false) { - let stdinFields = try parseFieldsFromStdin() - fields.append(contentsOf: stdinFields) - } - - guard !fields.isEmpty else { - throw UpdateError.noFieldsProvided - } - - return fields - } - - /// Parse fields from JSON file - private static func parseFieldsFromJSONFile(_ filePath: String) throws -> [Field] { - do { - let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - return try fieldsInput.toFields() - } catch { - throw UpdateError.jsonFileError(filePath, error.localizedDescription) - } - } - - /// Parse fields from stdin - private static func parseFieldsFromStdin() throws -> [Field] { - let stdinData = FileHandle.standardInput.readDataToEndOfFile() - - guard !stdinData.isEmpty else { - throw UpdateError.emptyStdin - } - - do { - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: stdinData) - return try fieldsInput.toFields() - } catch { - throw UpdateError.stdinError(error.localizedDescription) - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/UploadAssetConfig.swift b/Examples/MistDemo/Sources/MistDemo/Configuration/UploadAssetConfig.swift deleted file mode 100644 index 35030b62..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/UploadAssetConfig.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// UploadAssetConfig.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit -public import ConfigKeyKit - -/// Configuration for upload-asset command -public struct UploadAssetConfig: Sendable, ConfigurationParseable { - public typealias ConfigReader = MistDemoConfiguration - public typealias BaseConfig = MistDemoConfig - - public let base: MistDemoConfig - public let file: String - public let recordType: String - public let fieldName: String - public let recordName: String? - public let output: OutputFormat - - public init( - base: MistDemoConfig, - file: String, - recordType: String, - fieldName: String, - recordName: String? = nil, - output: OutputFormat = .json - ) { - self.base = base - self.file = file - self.recordType = recordType - self.fieldName = fieldName - self.recordName = recordName - self.output = output - } - - /// Parse configuration from command line arguments - public init(configuration: MistDemoConfiguration, base: MistDemoConfig?) async throws { - let configReader = configuration - let baseConfig: MistDemoConfig - if let base = base { - baseConfig = base - } else { - baseConfig = try await MistDemoConfig(configuration: configuration, base: nil) - } - - // Get file path from configuration - guard let filePath = configReader.string(forKey: "file") else { - throw UploadAssetError.filePathRequired - } - - // Get record type (defaults to "Note") - let recordType = configReader.string(forKey: "record-type") ?? "Note" - - // Get field name (defaults to "image") - let fieldName = configReader.string(forKey: "field-name") ?? "image" - - // Parse optional record name - let recordName = configReader.string(forKey: "record-name") - - // Parse output format - let outputString = configReader.string(forKey: "output.format", default: "json") ?? "json" - let output = OutputFormat(rawValue: outputString) ?? .json - - self.init( - base: baseConfig, - file: filePath, - recordType: recordType, - fieldName: fieldName, - recordName: recordName, - output: output - ) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemo/Constants/MistDemoConstants.swift deleted file mode 100644 index 8bd98b56..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Constants/MistDemoConstants.swift +++ /dev/null @@ -1,199 +0,0 @@ -// -// MistDemoConstants.swift -// MistDemo -// -// Copyright © 2025 Leo Dion. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation - -/// Central constants for MistDemo application -public enum MistDemoConstants { - // MARK: - Configuration Keys - - /// Configuration key names used throughout the application - public enum ConfigKeys { - public static let apiToken = "api.token" - public static let webAuthToken = "web.auth.token" - public static let containerID = "container.id" - public static let environment = "environment" - public static let database = "database" - public static let recordType = "record.type" - public static let recordName = "record.name" - public static let zone = "zone" - public static let limit = "limit" - public static let fields = "fields" - public static let outputFormat = "output.format" - public static let sort = "sort" - public static let filter = "filter" - public static let noBrowser = "no.browser" - public static let host = "host" - public static let port = "port" - public static let jsonFile = "json.file" - public static let stdin = "stdin" - public static let recordChangeTag = "record.change.tag" - } - - // MARK: - Default Values - - /// Default values for configuration parameters - public enum Defaults { - public static let zone = "_defaultZone" - public static let recordType = "Note" - public static let host = "127.0.0.1" - public static let port = 8080 - public static let outputFormat = "json" - public static let queryLimit = 20 - public static let environment = "development" - public static let database = "private" - } - - // MARK: - Limits - - /// Numeric limits and ranges - public enum Limits { - public static let minQueryLimit = 1 - public static let maxQueryLimit = 200 - public static let randomSuffixMin = 1000 - public static let randomSuffixMax = 9999 - } - - // MARK: - Timeouts - - /// Timeout values in milliseconds - public enum Timeouts { - public static let authServer = 300_000 // 5 minutes - public static let authCompletionDelay = 1000 // 1 second - } - - // MARK: - Field Names - - /// Standard CloudKit field names - public enum FieldNames { - public static let recordName = "recordName" - public static let recordType = "recordType" - public static let recordChangeTag = "recordChangeTag" - public static let userRecordName = "userRecordName" - public static let firstName = "firstName" - public static let lastName = "lastName" - public static let emailAddress = "emailAddress" - public static let created = "created" - public static let modified = "modified" - public static let recordID = "recordID" - } - - // MARK: - CloudKit Parameters - - /// CloudKit API parameter names - public enum CloudKitParams { - public static let query = "query" - public static let zoneID = "zoneID" - public static let resultsLimit = "resultsLimit" - public static let desiredKeys = "desiredKeys" - public static let sortBy = "sortBy" - public static let filterBy = "filterBy" - public static let continuationMarker = "continuationMarker" - } - - // MARK: - Output Messages - - /// User-facing messages - public enum Messages { - // Authentication messages - public static let authServerStarting = "🚀 Starting CloudKit Authentication Server" - public static let authServerURL = "📍 Server URL: http://%@:%d" - public static let authApiToken = "🔑 API Token: %@" - public static let authServingFiles = "📁 Serving static files from: %@" - public static let authOpeningBrowser = "🌐 Opening browser..." - public static let authBrowserDisabled = "ℹ️ Browser opening disabled. Navigate to http://%@:%d manually" - public static let authWaiting = "⏳ Waiting for authentication..." - public static let authTimeout = " Timeout: 5 minutes" - public static let authCancel = " Press Ctrl+C to cancel" - public static let authSuccess = "✅ Authentication successful! Received token." - public static let authSuccessMessage = "Authentication successful! Token received." - - // Query messages - public static let noRecordsFound = "No records found" - public static let recordsFound = "Found %d record(s)" - - // Create messages - public static let recordCreated = "✅ Record Created Successfully" - public static let creatingRecord = "Creating record..." - - // Error messages - public static let missingAPIToken = "API token is required" - public static let missingWebAuthToken = "Web auth token is required for private/shared databases" - public static let invalidLimit = "Invalid limit %d. Must be between %d and %d." - public static let invalidSortFormat = "Invalid sort format" - public static let invalidFilterFormat = "Invalid filter format" - public static let noFieldsProvided = "No fields provided. Use --field, --json-file, or --stdin to specify fields." - } - - // MARK: - API Paths - - /// API endpoint paths - public enum APIPaths { - public static let api = "api" - public static let authenticate = "authenticate" - } - - // MARK: - Content Types - - /// HTTP content types - public enum ContentTypes { - public static let json = "application/json" - public static let html = "text/html" - public static let css = "text/css" - public static let javascript = "application/javascript" - } - - // MARK: - Resource Files - - /// Resource file names - public enum Resources { - public static let indexHTML = "index.html" - public static let resourcesFolder = "Resources" - public static let sourcesFolder = "Sources" - public static let mistDemoFolder = "MistDemo" - } - - // MARK: - Command Names - - /// CLI command names - public enum Commands { - public static let query = "query" - public static let create = "create" - public static let update = "update" - public static let currentUser = "current-user" - public static let authToken = "auth-token" - } - - // MARK: - Environment Variables - - /// Environment variable names - public enum EnvironmentVars { - public static let cloudKitAPIToken = "CLOUDKIT_API_TOKEN" - public static let cloudKitWebAuthToken = "CLOUDKIT_WEB_AUTH_TOKEN" - public static let cloudKitContainerID = "CLOUDKIT_CONTAINER_ID" - public static let cloudKitEnvironment = "CLOUDKIT_ENVIRONMENT" - public static let cloudKitDatabase = "CLOUDKIT_DATABASE" - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/ConfigError.swift b/Examples/MistDemo/Sources/MistDemo/Errors/ConfigError.swift deleted file mode 100644 index 7778ed0b..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Errors/ConfigError.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ConfigError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Configuration-specific errors -enum ConfigError: LocalizedError, Sendable { - case missingAPIToken - case invalidEnvironment(String) - case fileNotFound(String) - case invalidFormat(String, details: String) - case profileNotFound(String) - - // MARK: Internal - - var errorDescription: String? { - switch self { - case .missingAPIToken: - "CloudKit API token is required" - case let .invalidEnvironment(env): - "Invalid environment: \(env)" - case let .fileNotFound(path): - "Configuration file not found: \(path)" - case let .invalidFormat(format, details): - "Invalid \(format) format: \(details)" - case let .profileNotFound(profile): - "Profile not found: \(profile)" - } - } - - var recoverySuggestion: String? { - switch self { - case .missingAPIToken: - "Set CLOUDKIT_API_TOKEN environment variable or use --api-token flag" - case let .invalidEnvironment(env): - "Use 'development' or 'production' instead of '\(env)'" - case let .fileNotFound(path): - "Create a configuration file at \(path)" - case let .invalidFormat(format, _): - "Check your \(format) configuration file syntax" - case let .profileNotFound(profile): - "Check available profiles or create '\(profile)' profile" - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/CreateError.swift b/Examples/MistDemo/Sources/MistDemo/Errors/CreateError.swift deleted file mode 100644 index 3ce56a97..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Errors/CreateError.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// CreateError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Errors specific to create command -public enum CreateError: Error, LocalizedError { - case noFieldsProvided - case invalidJSONFormat(String) - case jsonFileError(String, String) - case emptyStdin - case stdinError(String) - case fieldConversionError(String, FieldType, String, String) - case operationFailed(String) - - public var errorDescription: String? { - switch self { - case .noFieldsProvided: - return MistDemoConstants.Messages.noFieldsProvided - case .invalidJSONFormat(let message): - return "Invalid JSON format: \(message)" - case .jsonFileError(let file, let error): - return "Error reading JSON file '\(file)': \(error)" - case .emptyStdin: - return "Empty stdin provided. Expected JSON object with field definitions." - case .stdinError(let error): - return "Error reading from stdin: \(error)" - case .fieldConversionError(let name, let type, let value, let error): - return "Failed to convert field '\(name)' of type '\(type.rawValue)' with value '\(value)': \(error)" - case .operationFailed(let message): - return "Create operation failed: \(message)" - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/ErrorOutput.swift b/Examples/MistDemo/Sources/MistDemo/Errors/ErrorOutput.swift deleted file mode 100644 index b35bf676..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Errors/ErrorOutput.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// ErrorOutput.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// JSON-formatted error output for consistent error reporting -public struct ErrorOutput: Sendable, Codable { - // MARK: Lifecycle - - public init(code: String, message: String, details: [String: String]? = nil, suggestion: String? = nil) { - self.error = ErrorDetail(code: code, message: message, details: details, suggestion: suggestion) - } - - // MARK: Public - - /// The error details - public let error: ErrorDetail - - // MARK: - Error Detail - - /// Detailed error information - public struct ErrorDetail: Sendable, Codable { - // MARK: Lifecycle - - public init(code: String, message: String, details: [String: String]? = nil, suggestion: String? = nil) { - self.code = code - self.message = message - self.details = details - self.suggestion = suggestion - } - - // MARK: Public - - /// Error code (machine-readable) - public let code: String - - /// Human-readable error message - public let message: String - - /// Optional additional details about the error - public let details: [String: String]? - - /// Optional suggestion for recovery - public let suggestion: String? - } -} - diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/QueryError.swift b/Examples/MistDemo/Sources/MistDemo/Errors/QueryError.swift deleted file mode 100644 index 70ff9422..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Errors/QueryError.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// QueryError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Errors specific to query command -public enum QueryError: Error, LocalizedError { - case invalidLimit(Int) - case invalidFilter(String, expected: String) - case emptyFieldName(String) - case invalidSortOrder(String, available: [String]) - case unsupportedOperator(String) - case operationFailed(String) - - public var errorDescription: String? { - switch self { - case .invalidLimit(let limit): - return String(format: MistDemoConstants.Messages.invalidLimit, limit, MistDemoConstants.Limits.minQueryLimit, MistDemoConstants.Limits.maxQueryLimit) - case .invalidFilter(let filter, let expected): - return "Invalid filter '\(filter)'. Expected format: \(expected)" - case .emptyFieldName(let filter): - return "Empty field name in filter '\(filter)'" - case .invalidSortOrder(let order, let available): - return "Invalid sort order '\(order)'. Available orders: \(available.joined(separator: ", "))" - case .unsupportedOperator(let op): - return "Unsupported filter operator '\(op)'. Supported: eq, ne, gt, gte, lt, lte, contains, begins_with, in, not_in" - case .operationFailed(let message): - return "Query operation failed: \(message)" - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/UpdateError.swift b/Examples/MistDemo/Sources/MistDemo/Errors/UpdateError.swift deleted file mode 100644 index 565abb6c..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Errors/UpdateError.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// UpdateError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Errors that can occur during update command execution -public enum UpdateError: Error, LocalizedError { - case recordNameRequired - case noFieldsProvided - case fieldConversionError(String, FieldType, String, String) - case jsonFileError(String, String) - case emptyStdin - case stdinError(String) - case operationFailed(String) - - public var errorDescription: String? { - switch self { - case .recordNameRequired: - return "Record name is required for update operations. Use --record-name " - case .noFieldsProvided: - return "No fields provided. Use --field, --json-file, or --stdin to specify fields to update" - case .fieldConversionError(let fieldName, let fieldType, let value, let reason): - return "Failed to convert field '\(fieldName)' of type '\(fieldType.rawValue)' with value '\(value)': \(reason)" - case .jsonFileError(let filename, let reason): - return "Failed to read JSON file '\(filename)': \(reason)" - case .emptyStdin: - return "Empty stdin. Provide JSON data when using --stdin" - case .stdinError(let reason): - return "Failed to read from stdin: \(reason)" - case .operationFailed(let reason): - return "Update operation failed: \(reason)" - } - } - - public var recoverySuggestion: String? { - switch self { - case .recordNameRequired: - return "Specify a record name: mistdemo update --record-name my-record-123 --field \"title:string:Updated\"" - case .noFieldsProvided: - return "Provide at least one field to update using --field, --json-file, or --stdin" - case .fieldConversionError: - return "Check that the field value matches the expected type. Use --help for field type information" - case .jsonFileError: - return "Ensure the JSON file exists and contains valid JSON" - case .emptyStdin: - return "Pipe JSON data to stdin: echo '{\"title\":\"Updated\"}' | mistdemo update --record-name my-record --stdin" - case .stdinError, .operationFailed: - return nil - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/UploadAssetError.swift b/Examples/MistDemo/Sources/MistDemo/Errors/UploadAssetError.swift deleted file mode 100644 index 13d1f9ae..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Errors/UploadAssetError.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// UploadAssetError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Errors that can occur during asset upload operations -public enum UploadAssetError: Error, LocalizedError { - case filePathRequired - case recordTypeRequired - case fieldNameRequired - case fileNotFound(String) - case fileTooLarge(Int64, maximum: Int64) - case invalidRecordType(String) - case operationFailed(String) - - public var errorDescription: String? { - switch self { - case .filePathRequired: - return "File path is required. Usage: mistdemo upload-asset --file --record-type --field-name " - case .recordTypeRequired: - return "Record type is required. Specify with --record-type " - case .fieldNameRequired: - return "Field name is required. Specify with --field-name " - case .fileNotFound(let path): - return "File not found at path: \(path)" - case .fileTooLarge(let size, let maximum): - let sizeMB = Double(size) / 1024 / 1024 - let maxMB = Double(maximum) / 1024 / 1024 - return "File size (\(String(format: "%.2f", sizeMB)) MB) exceeds maximum (\(String(format: "%.2f", maxMB)) MB)" - case .invalidRecordType(let type): - return "Invalid record type: \(type)" - case .operationFailed(let message): - return "Upload operation failed: \(message)" - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Extensions/ConfigKey+MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/Extensions/ConfigKey+MistDemo.swift deleted file mode 100644 index 13624a6d..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Extensions/ConfigKey+MistDemo.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ConfigKey+MistDemo.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import ConfigKeyKit -import Foundation - -// MARK: - MistDemo-Specific Config Key Helpers - -extension ConfigKey { - /// Convenience initializer for keys with MISTDEMO prefix - /// - Parameters: - /// - base: Base key string (e.g., "cloudkit.container.id") - /// - defaultVal: Required default value - public init(mistDemoPrefixed base: String, default defaultVal: Value) { - self.init(base, envPrefix: "MISTDEMO", default: defaultVal) - } -} - -extension OptionalConfigKey { - /// Convenience initializer for keys with MISTDEMO prefix - /// - Parameter base: Base key string (e.g., "api.token") - public init(mistDemoPrefixed base: String) { - self.init(base, envPrefix: "MISTDEMO") - } -} - -extension ConfigKey where Value == Bool { - /// Convenience initializer for boolean keys with MISTDEMO prefix - /// - Parameters: - /// - base: Base key string (e.g., "debug.enabled") - /// - defaultVal: Default value (defaults to false) - public init(mistDemoPrefixed base: String, default defaultVal: Bool = false) { - self.init(base, envPrefix: "MISTDEMO", default: defaultVal) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Extensions/FieldValue+FieldType.swift b/Examples/MistDemo/Sources/MistDemo/Extensions/FieldValue+FieldType.swift deleted file mode 100644 index d4f30801..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Extensions/FieldValue+FieldType.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// FieldValue+FieldType.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -public import MistKit - -extension FieldValue { - /// Initialize FieldValue from a parsed value and field type - /// - /// This convenience initializer simplifies converting MistDemo's parsed field values - /// into MistKit's FieldValue enum cases. It handles type conversion and validation. - /// - /// - Parameters: - /// - value: The parsed value (from FieldType.convertValue) - /// - fieldType: The MistDemo FieldType that specifies what type this value should be - /// - Returns: A FieldValue if the conversion is successful, nil otherwise - /// - /// ## Example - /// ```swift - /// let field = try Field(parsing: "title:string:Hello") - /// let convertedValue = try field.type.convertValue(field.value) - /// if let fieldValue = FieldValue(value: convertedValue, fieldType: field.type) { - /// // Use fieldValue in CloudKit operations - /// } - /// ``` - public init?(value: Any, fieldType: FieldType) { - switch fieldType { - case .string: - guard let stringValue = value as? String else { return nil } - self = .string(stringValue) - - case .int64: - if let intValue = value as? Int64 { - self = .int64(Int(intValue)) - } else if let intValue = value as? Int { - self = .int64(intValue) - } else { - return nil - } - - case .double: - guard let doubleValue = value as? Double else { return nil } - self = .double(doubleValue) - - case .timestamp: - guard let dateValue = value as? Date else { return nil } - self = .date(dateValue) - - case .bytes: - guard let stringValue = value as? String else { return nil } - self = .bytes(stringValue) - - case .asset: - // Value should be the URL from upload token - guard let urlString = value as? String else { return nil } - let asset = FieldValue.Asset( - fileChecksum: nil, - size: nil, - referenceChecksum: nil, - wrappingKey: nil, - receipt: nil, - downloadURL: urlString - ) - self = .asset(asset) - - case .location, .reference: - // These complex types require specialized handling - // For now, return nil to indicate they're not supported via simple conversion - return nil - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestData.swift b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestData.swift deleted file mode 100644 index a300fb2a..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestData.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// IntegrationTestData.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Test data generation utilities for integration tests -struct IntegrationTestData { - /// CloudKit record type for integration tests - static let recordType = "MistKitIntegrationTest" - - /// Generate minimal PNG-like binary data for upload testing. - /// - /// Produces data with a valid PNG signature, IHDR, IDAT, and IEND structure, - /// but padding chunks use zeroed CRC32 values (invalid). Not standards-compliant - /// and will be rejected by PNG decoders; suitable only as raw binary test payloads. - /// - Parameter sizeKB: Desired size in kilobytes (default: 10) - /// - Returns: PNG-like binary data - static func generateTestImage(sizeKB: Int = 10) -> Data { - // Minimal valid 1x1 pixel PNG - // PNG signature - var data = Data([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]) - - // IHDR chunk (image header) for 1x1 pixel RGBA image - let ihdrData: [UInt8] = [ - 0x00, 0x00, 0x00, 0x0D, // Chunk length: 13 bytes - 0x49, 0x48, 0x44, 0x52, // Chunk type: "IHDR" - 0x00, 0x00, 0x00, 0x01, // Width: 1 - 0x00, 0x00, 0x00, 0x01, // Height: 1 - 0x08, // Bit depth: 8 - 0x06, // Color type: RGBA - 0x00, // Compression: deflate - 0x00, // Filter: adaptive - 0x00, // Interlace: none - 0x1F, 0x15, 0xC4, 0x89 // CRC32 checksum - ] - data.append(contentsOf: ihdrData) - - // IDAT chunk (image data) - minimal compressed pixel data - let idatData: [UInt8] = [ - 0x00, 0x00, 0x00, 0x0C, // Chunk length: 12 bytes - 0x49, 0x44, 0x41, 0x54, // Chunk type: "IDAT" - 0x08, 0x1D, 0x01, 0x02, 0x00, 0xFD, 0xFF, // Compressed data - 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, - 0xE2, 0x21, 0xBC, 0x33 // CRC32 checksum - ] - data.append(contentsOf: idatData) - - // IEND chunk (image trailer) - let iendData: [UInt8] = [ - 0x00, 0x00, 0x00, 0x00, // Chunk length: 0 - 0x49, 0x45, 0x4E, 0x44, // Chunk type: "IEND" - 0xAE, 0x42, 0x60, 0x82 // CRC32 checksum - ] - data.append(contentsOf: iendData) - - // Pad to requested size with additional IDAT chunks if needed - let targetSize = sizeKB * 1024 - while data.count < targetSize { - // Add padding IDAT chunks - let remainingBytes = targetSize - data.count - let chunkSize = min(8192, remainingBytes - 12) // Leave room for chunk overhead - - if chunkSize <= 0 { - break - } - - // Chunk length (4 bytes) - let lengthBytes: [UInt8] = [ - UInt8((chunkSize >> 24) & 0xFF), - UInt8((chunkSize >> 16) & 0xFF), - UInt8((chunkSize >> 8) & 0xFF), - UInt8(chunkSize & 0xFF) - ] - data.append(contentsOf: lengthBytes) - - // Chunk type: "IDAT" - data.append(contentsOf: [0x49, 0x44, 0x41, 0x54]) - - // Padding data - data.append(contentsOf: Array(repeating: UInt8(0x00), count: chunkSize)) - - // Simple CRC32 (not accurate, but sufficient for test data) - data.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) - } - - return data - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestError.swift deleted file mode 100644 index 5528ad53..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestError.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// IntegrationTestError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Errors that can occur during integration testing -enum IntegrationTestError: LocalizedError, Sendable { - case zoneNotFound(String) - case uploadFailed(String) - case recordCreationFailed(String) - case syncTokenMissing - case verificationFailed(String) - case cleanupFailed(String) - case noRecordsCreated - case missingWebAuthToken - - var errorDescription: String? { - switch self { - case .zoneNotFound(let zone): - return "Zone not found: \(zone)" - case .uploadFailed(let reason): - return "Asset upload failed: \(reason)" - case .recordCreationFailed(let reason): - return "Record creation failed: \(reason)" - case .syncTokenMissing: - return "Sync token not available from initial fetch" - case .verificationFailed(let reason): - return "Verification failed: \(reason)" - case .cleanupFailed(let reason): - return "Cleanup failed: \(reason)" - case .noRecordsCreated: - return "No records were successfully created" - case .missingWebAuthToken: - return "Web auth token is required for private database tests. Run 'mistdemo auth-token' first." - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift deleted file mode 100644 index 1844be6f..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift +++ /dev/null @@ -1,547 +0,0 @@ -// -// IntegrationTestRunner.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Orchestrates comprehensive integration tests for CloudKit operations -struct IntegrationTestRunner { - let service: CloudKitService - let containerIdentifier: String - let database: MistKit.Database - let recordCount: Int - let assetSizeKB: Int - let skipCleanup: Bool - let verbose: Bool - - // MARK: - Public Workflows - - /// Run the public-database workflow covering all non-user-scoped API methods. - func runBasicWorkflow() async throws { - printWorkflowHeader() - try await runCorePhases(service: service) - printSuccessSummary(includeUserPhases: false) - } - - /// Run the private-database workflow covering all API methods including user-identity endpoints. - func runPrivateWorkflow() async throws { - printWorkflowHeader() - try await runCorePhases(service: service) - let userInfo = try await phaseFetchCurrentUser(service: service) - try await phaseDiscoverUserIdentities(service: service, userRecordName: userInfo.userRecordName) - printSuccessSummary(includeUserPhases: true) - } - - // MARK: - Core Phase Runner - - private func runCorePhases(service: CloudKitService) async throws { - var createdRecordNames: [String] = [] - var syncToken: String? - - do { - if database == .private { - try await phaseListZones(service: service) - } - try await phaseLookupZone(service: service) - if database == .private { - try await phaseFetchZoneChanges(service: service) - } - let assetToken = try await phase3UploadAsset(service: service) - createdRecordNames = try await phase4CreateRecords(service: service, assetToken: assetToken) - try await phaseQueryRecords(service: service, createdRecordNames: createdRecordNames) - try await phaseLookupRecords(service: service, recordNames: createdRecordNames) - if database == .private { - syncToken = try await phase7InitialSync(service: service, createdRecordNames: createdRecordNames) - } - try await phase8ModifyRecords(service: service, createdRecordNames: createdRecordNames) - if database == .private { - try await phase9IncrementalSync(service: service, syncToken: syncToken, createdRecordNames: createdRecordNames) - } - try await phase10FinalVerification(service: service) - if !skipCleanup { - try await phase11Cleanup(service: service, createdRecordNames: createdRecordNames) - } else { - printSkippedCleanup(recordNames: createdRecordNames) - } - } catch { - print("\n❌ Error: \(error)") - if !createdRecordNames.isEmpty && !skipCleanup { - print("\n⚠️ Attempting cleanup of \(createdRecordNames.count) test records...") - try? await phase11Cleanup(service: service, createdRecordNames: createdRecordNames) - } - throw error - } - } - - // MARK: - Phase 1: List All Zones - - private func phaseListZones(service: CloudKitService) async throws { - print("\n📋 Phase 1: List all zones") - - let zones = try await service.listZones() - - guard !zones.isEmpty else { - throw IntegrationTestError.zoneNotFound("(any zone)") - } - - print("✅ Found \(zones.count) zone(s)") - - if verbose { - for zone in zones { - print(" - \(zone.zoneName)") - } - } - } - - // MARK: - Phase 2: Lookup Specific Zone - - private func phaseLookupZone(service: CloudKitService) async throws { - print("\n📋 Phase 2: Lookup default zone") - - let zones = try await service.lookupZones(zoneIDs: [.defaultZone]) - - guard !zones.isEmpty else { - throw IntegrationTestError.zoneNotFound("_defaultZone") - } - - let zone = zones[0] - print("✅ Found zone: \(zone.zoneName)") - - if verbose { - if let owner = zone.ownerRecordName { - print(" Owner: \(owner)") - } - if !zone.capabilities.isEmpty { - print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") - } - } - } - - // MARK: - Phase 2b: Fetch Zone Changes - - private func phaseFetchZoneChanges(service: CloudKitService) async throws { - print("\n🔄 Phase 2b: Fetch zone changes") - - do { - let result = try await service.fetchZoneChanges() - print("✅ Fetched \(result.zones.count) zone(s)") - if verbose { - for zone in result.zones { - print(" - \(zone.zoneName)") - } - if let token = result.syncToken { - print(" Sync token: \(token.prefix(30))...") - } - } - } catch { - print("⚠️ fetchZoneChanges failed (non-fatal): \(error)") - } - } - - // MARK: - Phase 3: Asset Upload - - private func phase3UploadAsset(service: CloudKitService) async throws -> AssetUploadReceipt { - print("\n📤 Phase 3: Upload test asset") - - let testData = IntegrationTestData.generateTestImage(sizeKB: assetSizeKB) - let sizeInMB = Double(testData.count) / 1024 / 1024 - - if verbose { - print(" Uploading \(testData.count) bytes (\(String(format: "%.2f", sizeInMB)) MB)...") - } - - let receipt = try await service.uploadAssets( - data: testData, - recordType: IntegrationTestData.recordType, - fieldName: "image" - ) - - print("✅ Uploaded asset: \(testData.count) bytes") - - if verbose { - print(" Record: \(receipt.recordName)") - print(" Field: \(receipt.fieldName)") - } - - return receipt - } - - // MARK: - Phase 4: Create Records - - private func phase4CreateRecords( - service: CloudKitService, - assetToken: AssetUploadReceipt - ) async throws -> [String] { - print("\n📝 Phase 4: Create records with assets") - - if verbose { - print(" Creating \(recordCount) records...") - } - - var createdRecordNames: [String] = [] - - for i in 1...recordCount { - let recordName = "mistkit-test-\(UUID().uuidString.lowercased())" - let record = try await service.createRecord( - recordType: IntegrationTestData.recordType, - recordName: recordName, - fields: [ - "title": .string("Test Record \(i)"), - "index": .int64(i), - "image": .asset(assetToken.asset), - "createdAt": .date(Date()) - ] - ) - createdRecordNames.append(record.recordName) - if verbose { - print(" ✅ Created: \(record.recordName)") - } - } - - guard !createdRecordNames.isEmpty else { - throw IntegrationTestError.noRecordsCreated - } - - print("✅ Created \(createdRecordNames.count) records") - - return createdRecordNames - } - - // MARK: - Phase 5: Query Records - - private func phaseQueryRecords( - service: CloudKitService, - createdRecordNames: [String] - ) async throws { - print("\n🔍 Phase 5: Query records by type") - - do { - let records = try await service.queryRecords(recordType: IntegrationTestData.recordType) - print("✅ Queried \(records.count) record(s) of type '\(IntegrationTestData.recordType)'") - if verbose { - let ours = records.filter { createdRecordNames.contains($0.recordName) } - print(" Found \(ours.count) of our \(createdRecordNames.count) test records") - } - } catch CloudKitError.httpErrorWithDetails(statusCode: 404, serverErrorCode: _, reason: _) where true { - // Schema propagation in development can lag behind the first write. - // lookupRecords (phase 6) already verifies the records exist by name. - print("⚠️ queryRecords returned NOT_FOUND — schema may not be indexed yet (non-fatal)") - } - } - - // MARK: - Phase 6: Lookup Records by Name - - private func phaseLookupRecords( - service: CloudKitService, - recordNames: [String] - ) async throws { - let lookupNames = Array(recordNames.prefix(min(3, recordNames.count))) - print("\n🔍 Phase 6: Lookup \(lookupNames.count) record(s) by name") - - let records = try await service.lookupRecords(recordNames: lookupNames) - - print("✅ Looked up \(records.count) record(s)") - - if verbose { - for record in records { - print(" - \(record.recordName)") - } - } - } - - // MARK: - Phase 7: Initial Sync - - private func phase7InitialSync( - service: CloudKitService, - createdRecordNames: [String] - ) async throws -> String? { - print("\n🔄 Phase 7: Initial sync (fetch all changes)") - - do { - let initialResult = try await service.fetchRecordChanges() - - print("✅ Fetched \(initialResult.records.count) records") - - if verbose { - if let token = initialResult.syncToken { - print(" Sync token: \(token.prefix(30))...") - } - print(" More coming: \(initialResult.moreComing)") - } - - let ourRecords = initialResult.records.filter { createdRecordNames.contains($0.recordName) } - print(" Found \(ourRecords.count) of our test records") - - if ourRecords.count != createdRecordNames.count && verbose { - print(" ⚠️ Expected \(createdRecordNames.count), found \(ourRecords.count)") - print(" (Records may not be immediately available)") - } - - return initialResult.syncToken - } catch { - print("⚠️ fetchRecordChanges failed (non-fatal, change tracking requires custom zones): \(error)") - return nil - } - } - - // MARK: - Phase 8: Modify Records - - private func phase8ModifyRecords( - service: CloudKitService, - createdRecordNames: [String] - ) async throws { - print("\n✏️ Phase 8: Modify some records") - - let recordsToUpdate = Array(createdRecordNames.prefix(min(3, createdRecordNames.count))) - - let operations = recordsToUpdate.enumerated().map { (i, recordName) in - RecordOperation( - operationType: .forceReplace, - recordType: IntegrationTestData.recordType, - recordName: recordName, - fields: [ - "title": .string("Updated Record \(i + 1)"), - "modified": .int64(1) - ] - ) - } - - _ = try await service.modifyRecords(operations) - - if verbose { - for recordName in recordsToUpdate { - print(" ✅ Updated: \(recordName)") - } - } - - print("✅ Updated \(recordsToUpdate.count) records") - } - - // MARK: - Phase 9: Incremental Sync - - private func phase9IncrementalSync( - service: CloudKitService, - syncToken: String?, - createdRecordNames: [String] - ) async throws { - print("\n🔄 Phase 9: Incremental sync (fetch only changes)") - - guard let token = syncToken else { - print("⚠️ No sync token available — skipping incremental sync (change tracking requires custom zones)") - return - } - - if verbose { - print(" Using sync token: \(token.prefix(30))...") - } - - do { - let incrementalResult = try await service.fetchRecordChanges(syncToken: token) - - print("✅ Fetched \(incrementalResult.records.count) changed records") - - if verbose, let newToken = incrementalResult.syncToken { - print(" New sync token: \(newToken.prefix(30))...") - } - - let changedRecords = incrementalResult.records.filter { createdRecordNames.contains($0.recordName) } - print(" Found \(changedRecords.count) of our modified records") - - if verbose && !changedRecords.isEmpty { - print(" Modified records:") - for record in changedRecords { - print(" - \(record.recordName)") - } - } - } catch { - print("⚠️ fetchRecordChanges (incremental) failed (non-fatal): \(error)") - } - } - - // MARK: - Phase 10: Final Zone Verification - - private func phase10FinalVerification(service: CloudKitService) async throws { - print("\n🔍 Phase 10: Final zone verification") - - let finalZones = try await service.lookupZones(zoneIDs: [.defaultZone]) - - guard !finalZones.isEmpty else { - throw IntegrationTestError.verificationFailed("Zone not found after operations") - } - - print("✅ Zone verification complete") - } - - // MARK: - Phase 11: Cleanup - - private func phase11Cleanup( - service: CloudKitService, - createdRecordNames: [String] - ) async throws { - print("\n🧹 Phase 11: Cleanup test records") - - var deletedCount = 0 - - // Use forceDelete so no recordChangeTag is required. - let deleteOps = createdRecordNames.map { recordName in - RecordOperation( - operationType: .forceDelete, - recordType: IntegrationTestData.recordType, - recordName: recordName - ) - } - - do { - _ = try await service.modifyRecords(deleteOps) - deletedCount = createdRecordNames.count - if verbose { - for name in createdRecordNames { print(" ✅ Deleted: \(name)") } - } - } catch { - if verbose { print(" ⚠️ Batch delete failed: \(error)") } - } - - print("✅ Deleted \(deletedCount) test records") - - if deletedCount < createdRecordNames.count { - print(" ⚠️ Failed to delete \(createdRecordNames.count - deletedCount) records") - } - } - - // MARK: - Phase 12: Fetch Current User (private only) - - @discardableResult - private func phaseFetchCurrentUser(service: CloudKitService) async throws -> UserInfo { - print("\n👤 Phase 12: Fetch current user") - - let userInfo = try await service.fetchCurrentUser() - - print("✅ Current user: \(userInfo.userRecordName)") - - if verbose { - if let firstName = userInfo.firstName { print(" First name: \(firstName)") } - if let lastName = userInfo.lastName { print(" Last name: \(lastName)") } - } - - return userInfo - } - - // MARK: - Phase 13: Discover User Identities (private only) - - private func phaseDiscoverUserIdentities( - service: CloudKitService, - userRecordName: String - ) async throws { - print("\n👥 Phase 13: Discover user identities") - - let lookupInfos = [UserIdentityLookupInfo(userRecordName: userRecordName)] - let identities = try await service.discoverUserIdentities(lookupInfos: lookupInfos) - - print("✅ Discovered \(identities.count) user identit\(identities.count == 1 ? "y" : "ies")") - - if verbose { - for identity in identities { - if let name = identity.userRecordName { print(" - \(name)") } - } - } - } - - // MARK: - Helpers - - private func printWorkflowHeader() { - print("\n" + String(repeating: "=", count: 80)) - print("🧪 Integration Test Suite: CloudKit Operations") - print(String(repeating: "=", count: 80)) - print("Container: \(containerIdentifier)") - print("Database: \(database == .public ? "public" : "private")") - print("Record Count: \(recordCount)") - print("Asset Size: \(assetSizeKB) KB") - print(String(repeating: "=", count: 80)) - } - - private func printSkippedCleanup(recordNames: [String]) { - print("\n⚠️ Skipping cleanup (--skip-cleanup flag set)") - print(" Test records left in CloudKit:") - for name in recordNames { print(" - \(name)") } - print("\nTo manually cleanup these records:") - print(" 1. Visit https://icloud.developer.apple.com/dashboard/") - print(" 2. Select your container: \(containerIdentifier)") - print(" 3. Navigate to \(database == .public ? "Public" : "Private") Database → Records") - print(" 4. Search for record type: \(IntegrationTestData.recordType)") - } - - private func printSuccessSummary(includeUserPhases: Bool) { - print("\n" + String(repeating: "=", count: 80)) - print("✅ Integration Test Complete!") - print(String(repeating: "=", count: 80)) - print("\nPhases Completed:") - if database == .private { - print(" ✅ Phase 1: List all zones (listZones)") - } else { - print(" ⏭️ Phase 1: List all zones (listZones — private db only)") - } - print(" ✅ Phase 2: Lookup default zone (lookupZones)") - if database == .private { - print(" ✅ Phase 2b: Fetch zone changes (fetchZoneChanges)") - } else { - print(" ⏭️ Phase 2b: Fetch zone changes (fetchZoneChanges — private db only)") - } - print(" ✅ Phase 3: Upload test asset (uploadAssets)") - print(" ✅ Phase 4: Create records (createRecord)") - print(" ✅ Phase 5: Query records by type (queryRecords)") - print(" ✅ Phase 6: Lookup records by name (lookupRecords)") - if database == .private { - print(" ✅ Phase 7: Initial sync (fetchRecordChanges)") - } else { - print(" ⏭️ Phase 7: Initial sync (fetchRecordChanges — private db only)") - } - print(" ✅ Phase 8: Modify records (updateRecord)") - if database == .private { - print(" ✅ Phase 9: Incremental sync (fetchRecordChanges)") - } else { - print(" ⏭️ Phase 9: Incremental sync (fetchRecordChanges — private db only)") - } - print(" ✅ Phase 10: Final zone check (lookupZones)") - if !skipCleanup { - print(" ✅ Phase 11: Cleanup (deleteRecord)") - } else { - print(" ⏭️ Phase 11: Cleanup skipped") - } - if includeUserPhases { - print(" ✅ Phase 12: Fetch current user (fetchCurrentUser)") - print(" ✅ Phase 13: Discover identities (discoverUserIdentities)") - } - print("\n💡 Next steps:") - print(" • Run with --verbose for detailed output") - print(" • Use --skip-cleanup to inspect records in CloudKit Console") - if !includeUserPhases { - print(" • Run 'mistdemo test-private' to also test user-identity APIs") - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift index 8d6ccfcf..2aa28bd9 100644 --- a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift @@ -27,133 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import ConfigKeyKit - -// MARK: - Main Command Group - -// MARK: - CloudKit Command Protocol - -/// Protocol for commands that interact with CloudKit -protocol CloudKitCommand { - var containerIdentifier: String { get } - var apiToken: String { get } - var environment: String { get } -} - -extension CloudKitCommand { - /// Resolve API token from option or environment variable - func resolvedApiToken() -> String { - apiToken.isEmpty ? - EnvironmentConfig.getOptional(EnvironmentConfig.Keys.cloudKitAPIToken) ?? "" : - apiToken - } - - /// Convert environment string to MistKit Environment - func cloudKitEnvironment() -> MistKit.Environment { - environment == "production" ? .production : .development - } -} - -// MARK: - Main Command Group +import MistDemoKit @main -struct MistDemo { - @MainActor - static func main() async throws { - let registry = CommandRegistry.shared - - // Register available commands - await registry.register(AuthTokenCommand.self) - await registry.register(CurrentUserCommand.self) - await registry.register(QueryCommand.self) - await registry.register(CreateCommand.self) - await registry.register(UpdateCommand.self) - await registry.register(UploadAssetCommand.self) - await registry.register(DemoInFilterCommand.self) - await registry.register(LookupZonesCommand.self) - await registry.register(FetchChangesCommand.self) - await registry.register(TestIntegrationCommand.self) - await registry.register(TestPrivateCommand.self) - - // Parse command line arguments - let parser = CommandLineParser() - - // Check for help - if parser.isHelpRequested() { - if let commandName = parser.parseCommandName() { - await printCommandHelp(commandName, registry: registry) - } else { - await printGeneralHelp(registry: registry) - } - return - } - - // Check if a command was specified - if let commandName = parser.parseCommandName() { - // Execute specific command - try await executeCommand(commandName, registry: registry) - } else { - // Show error and available commands - await printMissingCommandError(registry: registry) - } - } - - /// Execute a specific command - private static func executeCommand(_ commandName: String, registry: CommandRegistry) async throws { - do { - let command = try await registry.createCommand(named: commandName) - try await command.execute() - } catch let error as CommandRegistryError { - print("❌ \(error.localizedDescription)") - let availableCommands = await registry.availableCommands - print("Available commands: \(availableCommands.joined(separator: ", "))") - print("Run 'mistdemo help' for usage information.") - throw error - } - } - - /// Print general help +internal enum MistDemo { @MainActor - private static func printGeneralHelp(registry: CommandRegistry) async { - print("MistDemo - CloudKit Web Services Command Line Tool") - print("") - print("USAGE:") - print(" mistdemo [options]") - print("") - print("COMMANDS:") - let availableCommands = await registry.availableCommands - for commandName in availableCommands { - if let metadata = await registry.metadata(for: commandName) { - let paddedName = commandName.padding(toLength: 12, withPad: " ", startingAt: 0) - print(" \(paddedName) \(metadata.abstract)") - } - } - print("") - print("OPTIONS:") - print(" --help, -h Show help information") - print("") - print("Run 'mistdemo --help' for command-specific help.") + internal static func main() async throws { + try await MistDemoRunner.run() } - - /// Print command-specific help - @MainActor - private static func printCommandHelp(_ commandName: String, registry: CommandRegistry) async { - if let metadata = await registry.metadata(for: commandName) { - print(metadata.helpText) - } else { - print("Unknown command: \(commandName)") - await printGeneralHelp(registry: registry) - } - } - - /// Print error when no command is specified - @MainActor - private static func printMissingCommandError(registry: CommandRegistry) async { - print("❌ No command specified.") - print("💡 Use the command-based interface:") - print("") - await printGeneralHelp(registry: registry) - } -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/MistDemo/Models/AuthRequest.swift b/Examples/MistDemo/Sources/MistDemo/Models/AuthRequest.swift deleted file mode 100644 index 3c05d9c0..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Models/AuthRequest.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// AuthRequest.swift -// MistDemo -// -// Created by Leo Dion on 7/9/25. -// - -import Foundation - -/// Request model for authentication callback from CloudKit Web Services. -/// -/// This model is used by the AuthTokenCommand's Hummingbird server to decode -/// incoming authentication data from CloudKit's OAuth flow. When a user -/// successfully authenticates with CloudKit, the redirect callback sends -/// this data to the local server. -/// -/// - Note: Used in AuthTokenCommand.swift line 84 for decoding Hummingbird route requests -internal struct AuthRequest: Decodable { - /// The session token provided by CloudKit after successful authentication - let sessionToken: String - - /// The user's CloudKit record name identifier - let userRecordName: String -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Models/AuthResponse.swift b/Examples/MistDemo/Sources/MistDemo/Models/AuthResponse.swift deleted file mode 100644 index 254ab91b..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Models/AuthResponse.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// AuthResponse.swift -// MistDemo -// -// Created by Leo Dion on 7/9/25. -// - -import Foundation - -/// Response model for authentication callback endpoints. -/// -/// This model is returned by the AuthTokenCommand's Hummingbird routes after -/// processing CloudKit authentication callbacks. It provides comprehensive -/// feedback about the authentication result, including user information and -/// available zones. -/// -/// - Note: Used in AuthTokenCommand.swift line 88 for route responses -internal struct AuthResponse: Encodable { - /// The authenticated user's CloudKit record name - let userRecordName: String - - /// CloudKit data retrieved during authentication (user info and zones) - let cloudKitData: CloudKitData - - /// Human-readable message describing the authentication result - let message: String -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Models/CloudKitData.swift b/Examples/MistDemo/Sources/MistDemo/Models/CloudKitData.swift deleted file mode 100644 index 7a3c69e5..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Models/CloudKitData.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// CloudKitData.swift -// MistDemo -// -// Created by Leo Dion on 7/9/25. -// - -import MistKit - -/// CloudKit user and zone data for authentication response. -/// -/// This model encapsulates CloudKit information retrieved during the -/// authentication flow, including user details and available zones. -/// It is used to serialize CloudKit information in auth flow responses. -/// -/// - Note: Used in AuthResponse.swift line 13 for encoding auth response data -internal struct CloudKitData: Encodable { - /// User information retrieved from CloudKit (nil if retrieval failed) - let user: UserInfo? - - /// List of available zones in the user's container - let zones: [ZoneInfo] - - /// Error message if any part of the CloudKit data retrieval failed - let error: String? -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/CSVEscaper.swift b/Examples/MistDemo/Sources/MistDemo/Output/Escapers/CSVEscaper.swift deleted file mode 100644 index 9b590f1c..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/CSVEscaper.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// CSVEscaper.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// CSV escaper conforming to RFC 4180 -public struct CSVEscaper: OutputEscaper { - public init() {} - - public func escape(_ string: String) -> String { - // Check if escaping is needed - // Use unicodeScalars to avoid Swift treating \r\n as a single grapheme cluster - let needsEscaping = string.unicodeScalars.contains { scalar in - switch scalar { - case ",", "\"", "\n", "\r", "\t": - return true - default: - return false - } - } - - // If no special characters, return as-is - guard needsEscaping else { - return string - } - - // Escape quotes by doubling them and wrap in quotes - let escaped = string.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/JSONEscaper.swift b/Examples/MistDemo/Sources/MistDemo/Output/Escapers/JSONEscaper.swift deleted file mode 100644 index 190b0853..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/JSONEscaper.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// JSONEscaper.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// JSON escaper (usually handled by JSONEncoder, but useful for manual JSON building) -public struct JSONEscaper: OutputEscaper { - public init() {} - - public func escape(_ string: String) -> String { - let escaped = string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - .replacingOccurrences(of: "\u{000C}", with: "\\f") - .replacingOccurrences(of: "\u{0008}", with: "\\b") - - return escaped - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/OutputEscaperFactory.swift b/Examples/MistDemo/Sources/MistDemo/Output/Escapers/OutputEscaperFactory.swift deleted file mode 100644 index 8aa2a21d..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/OutputEscaperFactory.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// OutputEscaperFactory.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Factory for creating output escapers based on output format -public enum OutputEscaperFactory { - /// Create an appropriate escaper for the given output format - /// - Parameter format: The output format - /// - Returns: An escaper configured for the specified format - public static func escaper(for format: OutputFormat) -> OutputEscaper { - switch format { - case .csv: - return CSVEscaper() - case .yaml: - return YAMLEscaper() - case .json: - return JSONEscaper() - case .table: - return TableEscaper() - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/TableEscaper.swift b/Examples/MistDemo/Sources/MistDemo/Output/Escapers/TableEscaper.swift deleted file mode 100644 index 442acbfa..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/TableEscaper.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// TableEscaper.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Table escaper for plain text table output -public struct TableEscaper: OutputEscaper { - public init() {} - - public func escape(_ string: String) -> String { - // For table output, replace newlines with spaces and trim - // This ensures single-line values in table cells - string - .replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\r", with: " ") - .replacingOccurrences(of: "\t", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/YAMLEscaper.swift b/Examples/MistDemo/Sources/MistDemo/Output/Escapers/YAMLEscaper.swift deleted file mode 100644 index 44df2a52..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Escapers/YAMLEscaper.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// YAMLEscaper.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// YAML escaper for proper string formatting -public struct YAMLEscaper: OutputEscaper { - public init() {} - - public func escape(_ string: String) -> String { - // Check if the string needs escaping - guard needsEscaping(string) else { - return string - } - - // For multi-line strings, use literal block scalar - if string.contains("\n") { - return blockScalar(string) - } - - // For single-line strings with special characters, use double quotes - let escaped = string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\t", with: "\\t") - .replacingOccurrences(of: "\r", with: "\\r") - - return "\"\(escaped)\"" - } - - // MARK: - Private Helpers - - /// Check if a string needs YAML escaping - private func needsEscaping(_ string: String) -> Bool { - // Empty strings need quotes - if string.isEmpty { - return true - } - - // Characters that are special only as the first char of a plain scalar - let firstCharSpecials: Set = [ - ":", "#", "@", "`", "|", ">", "'", "\"", - "[", "]", "{", "}", ",", "&", "*", "!", - "%", "\\", "?", "-", "<", "=", "~" - ] - - // Characters that are special anywhere in a plain scalar - let anyCharSpecials: Set = [ - ":", "#", "`", "|", ">", "\"", - "[", "]", "{", "}", ",", "&", "*", "!", - "%", "\\" - ] - - // Check first character for special cases - if let first = string.first { - if firstCharSpecials.contains(first) || first.isWhitespace { - return true - } - } - - // Check last character for whitespace - if let last = string.last, last.isWhitespace { - return true - } - - // Check for special patterns - let specialPatterns = [ - "yes", "no", "true", "false", "on", "off", - "null", "~", "YES", "NO", "TRUE", "FALSE", - "ON", "OFF", "NULL", "Yes", "No", "True", - "False", "On", "Off", "Null" - ] - - if specialPatterns.contains(string) { - return true - } - - // Check if it looks like a number - if Double(string) != nil || Int(string) != nil { - return true - } - - // Check for special characters in the string - for char in string { - if anyCharSpecials.contains(char) || char == "\n" || char == "\r" || char == "\t" { - return true - } - } - - return false - } - - /// Create a YAML block scalar for multi-line strings - private func blockScalar(_ string: String) -> String { - // Use literal block scalar (|) for multi-line strings - // This preserves line breaks and doesn't require escaping - let lines = string.split(separator: "\n", omittingEmptySubsequences: false) - - // Use literal scalar to preserve formatting - var result = "|\n" - - // Indent each line with 2 spaces (or 4 spaces for better readability) - for line in lines { - if line.isEmpty { - result += "\n" - } else { - result += " \(line)\n" - } - } - - // Remove trailing newline if original didn't have one - if !string.hasSuffix("\n") && result.hasSuffix("\n") { - result.removeLast() - } - - return result - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/CSVFormatter.swift b/Examples/MistDemo/Sources/MistDemo/Output/Formatters/CSVFormatter.swift deleted file mode 100644 index e9693e0a..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/CSVFormatter.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// CSVFormatter.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Formatter for CSV output -public struct CSVFormatter: OutputFormatter { - // MARK: Lifecycle - - public init() {} - - // MARK: Public - - public func format(_ value: T) throws -> String { - let escaper = CSVEscaper() - - // For CSV format, we need to handle specific types - if let recordInfo = value as? RecordInfo { - return formatRecord(recordInfo, escaper: escaper) - } else if let userInfo = value as? UserInfo { - return formatUser(userInfo, escaper: escaper) - } else { - // Fall back to JSON for unknown types - let jsonFormatter = JSONFormatter(pretty: false) - return try jsonFormatter.format(value) - } - } - - // MARK: Private - - private func formatRecord(_ record: RecordInfo, escaper: CSVEscaper) -> String { - var output = "" - - // Header - output += "Field,Value\n" - - // Basic fields - output += "recordName,\(escaper.escape(record.recordName))\n" - output += "recordType,\(escaper.escape(record.recordType))\n" - - // Custom fields - for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { - let valueString = FieldValueFormatter.displayString(fieldValue) - output += "\(escaper.escape(fieldName)),\(escaper.escape(valueString))\n" - } - - return output - } - - private func formatUser(_ user: UserInfo, escaper: CSVEscaper) -> String { - var output = "" - - // Header - output += "Field,Value\n" - - // User fields - output += "userRecordName,\(escaper.escape(user.userRecordName))\n" - - if let firstName = user.firstName { - output += "firstName,\(escaper.escape(firstName))\n" - } - if let lastName = user.lastName { - output += "lastName,\(escaper.escape(lastName))\n" - } - if let emailAddress = user.emailAddress { - output += "emailAddress,\(escaper.escape(emailAddress))\n" - } - - return output - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/OutputFormatterFactory.swift b/Examples/MistDemo/Sources/MistDemo/Output/Formatters/OutputFormatterFactory.swift deleted file mode 100644 index a85b48b8..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/OutputFormatterFactory.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// OutputFormatterFactory.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Factory for creating output formatters based on output format -public enum OutputFormatterFactory { - /// Create an appropriate formatter for the given output format - /// - Parameters: - /// - format: The output format - /// - pretty: Whether to use pretty printing (applies to JSON) - /// - Returns: A formatter configured for the specified format - public static func formatter(for format: OutputFormat, pretty: Bool = false) -> OutputFormatter { - switch format { - case .json: - return JSONFormatter(pretty: pretty) - case .table: - return TableFormatter() - case .csv: - return CSVFormatter() - case .yaml: - return YAMLFormatter() - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/TableFormatter.swift b/Examples/MistDemo/Sources/MistDemo/Output/Formatters/TableFormatter.swift deleted file mode 100644 index cc5b90b7..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/TableFormatter.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// TableFormatter.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Formatter for table output -public struct TableFormatter: OutputFormatter { - // MARK: Lifecycle - - public init() {} - - // MARK: Public - - public func format(_ value: T) throws -> String { - // For table format, we need to handle specific types - // since table formatting is inherently structure-dependent - if let recordInfo = value as? RecordInfo { - return try formatRecord(recordInfo) - } else if let userInfo = value as? UserInfo { - return try formatUser(userInfo) - } else { - // Fall back to JSON for unknown types - let jsonFormatter = JSONFormatter(pretty: true) - return try jsonFormatter.format(value) - } - } - - // MARK: Private - - private func formatRecord(_ record: RecordInfo) throws -> String { - let escaper = TableEscaper() - var output = "" - - output += "Record Name: \(escaper.escape(record.recordName))\n" - output += "Record Type: \(escaper.escape(record.recordType))\n" - - if !record.fields.isEmpty { - output += "Fields:\n" - for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { - let valueString = escaper.escape(FieldValueFormatter.displayString(fieldValue)) - output += " \(fieldName): \(valueString)\n" - } - } - - return output - } - - private func formatUser(_ user: UserInfo) throws -> String { - let escaper = TableEscaper() - var output = "" - - output += "User Record Name: \(escaper.escape(user.userRecordName))\n" - - if let firstName = user.firstName { - output += "First Name: \(escaper.escape(firstName))\n" - } - if let lastName = user.lastName { - output += "Last Name: \(escaper.escape(lastName))\n" - } - if let emailAddress = user.emailAddress { - output += "Email: \(escaper.escape(emailAddress))\n" - } - - return output - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/YAMLFormatter.swift b/Examples/MistDemo/Sources/MistDemo/Output/Formatters/YAMLFormatter.swift deleted file mode 100644 index 9040cc6a..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/Formatters/YAMLFormatter.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// YAMLFormatter.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Formatter for YAML output -public struct YAMLFormatter: OutputFormatter { - // MARK: Lifecycle - - public init() {} - - // MARK: Public - - public func format(_ value: T) throws -> String { - let escaper = YAMLEscaper() - - // For YAML format, we need to handle specific types - if let recordInfo = value as? RecordInfo { - return formatRecord(recordInfo, escaper: escaper) - } else if let userInfo = value as? UserInfo { - return formatUser(userInfo, escaper: escaper) - } else { - // Fall back to JSON for unknown types - let jsonFormatter = JSONFormatter(pretty: true) - return try jsonFormatter.format(value) - } - } - - // MARK: Private - - private func formatRecord(_ record: RecordInfo, escaper: YAMLEscaper) -> String { - var output = "" - - output += "recordName: \(escaper.escape(record.recordName))\n" - output += "recordType: \(escaper.escape(record.recordType))\n" - - if !record.fields.isEmpty { - output += "fields:\n" - for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { - let valueString = FieldValueFormatter.displayString(fieldValue) - output += " \(escaper.escape(fieldName)): \(escaper.escape(valueString))\n" - } - } - - return output - } - - private func formatUser(_ user: UserInfo, escaper: YAMLEscaper) -> String { - var output = "" - - output += "userRecordName: \(escaper.escape(user.userRecordName))\n" - - if let firstName = user.firstName { - output += "firstName: \(escaper.escape(firstName))\n" - } - if let lastName = user.lastName { - output += "lastName: \(escaper.escape(lastName))\n" - } - if let emailAddress = user.emailAddress { - output += "emailAddress: \(escaper.escape(emailAddress))\n" - } - - return output - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/FormattingError.swift b/Examples/MistDemo/Sources/MistDemo/Output/FormattingError.swift deleted file mode 100644 index ccdee33d..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/FormattingError.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// FormattingError.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Formatting errors -enum FormattingError: LocalizedError, Sendable { - case encodingFailed - case invalidStructure(String) - case unsupportedFormat(OutputFormat) - - // MARK: Internal - - var errorDescription: String? { - switch self { - case .encodingFailed: - "Failed to encode data to UTF-8 string" - case let .invalidStructure(message): - "Invalid data structure: \(message)" - case let .unsupportedFormat(format): - "\(format.rawValue) format is not yet implemented. Use 'json' format instead." - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/OutputEscaping.swift b/Examples/MistDemo/Sources/MistDemo/Output/OutputEscaping.swift deleted file mode 100644 index 21f410ed..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/OutputEscaping.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// OutputEscaping.swift -// MistDemo -// -// Copyright © 2025 Leo Dion. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation - -/// Utilities for escaping strings in various output formats -/// - Warning: Deprecated. Use protocol-based escapers (CSVEscaper, YAMLEscaper, JSONEscaper) instead. -@available(*, deprecated, message: "Use protocol-based escapers (CSVEscaper, YAMLEscaper, JSONEscaper) instead") -public enum OutputEscaping { - - // MARK: - CSV Escaping - - /// Escape a string for CSV output according to RFC 4180 - /// - Parameter string: The string to escape - /// - Returns: The escaped string suitable for CSV output - /// - Warning: Deprecated. Use CSVEscaper instead. - @available(*, deprecated, message: "Use CSVEscaper().escape(_:) instead") - public static func csvEscape(_ string: String) -> String { - // Check if escaping is needed - let needsEscaping = string.contains { character in - switch character { - case ",", "\"", "\n", "\r", "\t": - return true - default: - return false - } - } - - // If no special characters, return as-is - guard needsEscaping else { - return string - } - - // Escape quotes by doubling them and wrap in quotes - let escaped = string.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - } - - // MARK: - YAML Escaping - - /// Escape a string for YAML output - /// - Parameter string: The string to escape - /// - Returns: The escaped string suitable for YAML output - /// - Warning: Deprecated. Use YAMLEscaper instead. - @available(*, deprecated, message: "Use YAMLEscaper().escape(_:) instead") - public static func yamlEscape(_ string: String) -> String { - // Check if the string needs escaping - let needsEscaping = yamlNeedsEscaping(string) - - // If no escaping needed, return as-is - guard needsEscaping else { - return string - } - - // For multi-line strings, use literal block scalar - if string.contains("\n") { - return yamlBlockScalar(string) - } - - // For single-line strings with special characters, use double quotes - let escaped = string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\t", with: "\\t") - .replacingOccurrences(of: "\r", with: "\\r") - - return "\"\(escaped)\"" - } - - // MARK: - Private Helpers - - /// Check if a string needs YAML escaping - private static func yamlNeedsEscaping(_ string: String) -> Bool { - // Empty strings need quotes - if string.isEmpty { - return true - } - - // Check for YAML special characters and patterns - let specialChars: Set = [ - ":", "#", "@", "`", "|", ">", "'", "\"", - "[", "]", "{", "}", ",", "&", "*", "!", - "%", "\\", "?", "-", "<", "=", "~" - ] - - // Check first character for special cases - if let first = string.first { - if specialChars.contains(first) || first.isWhitespace { - return true - } - } - - // Check last character for whitespace - if let last = string.last, last.isWhitespace { - return true - } - - // Check for special patterns - let specialPatterns = [ - "yes", "no", "true", "false", "on", "off", - "null", "~", "YES", "NO", "TRUE", "FALSE", - "ON", "OFF", "NULL", "Yes", "No", "True", - "False", "On", "Off", "Null" - ] - - if specialPatterns.contains(string) { - return true - } - - // Check if it looks like a number - if Double(string) != nil || Int(string) != nil { - return true - } - - // Check for special characters in the string - for char in string { - if specialChars.contains(char) || char == "\n" || char == "\r" || char == "\t" { - return true - } - } - - return false - } - - /// Create a YAML block scalar for multi-line strings - private static func yamlBlockScalar(_ string: String) -> String { - // Use literal block scalar (|) for multi-line strings - // This preserves line breaks and doesn't require escaping - let lines = string.split(separator: "\n", omittingEmptySubsequences: false) - - // Check if we need folded scalar (>) or literal scalar (|) - // Use literal scalar to preserve formatting - var result = "|\n" - - // Indent each line with 2 spaces - for line in lines { - if line.isEmpty { - result += "\n" - } else { - result += " \(line)\n" - } - } - - // Remove trailing newline if original didn't have one - if !string.hasSuffix("\n") && result.hasSuffix("\n") { - result.removeLast() - } - - return result - } - - // MARK: - JSON Escaping - - /// Escape a string for JSON output (usually handled by JSONEncoder, but useful for manual JSON building) - /// - Parameter string: The string to escape - /// - Returns: The escaped string suitable for JSON output - /// - Warning: Deprecated. Use JSONEscaper instead. - @available(*, deprecated, message: "Use JSONEscaper().escape(_:) instead") - public static func jsonEscape(_ string: String) -> String { - let escaped = string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - .replacingOccurrences(of: "\u{000C}", with: "\\f") - .replacingOccurrences(of: "\u{0008}", with: "\\b") - - return escaped - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Output/OutputFormatter.swift b/Examples/MistDemo/Sources/MistDemo/Output/OutputFormatter.swift deleted file mode 100644 index 88f818b7..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Output/OutputFormatter.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// OutputFormatter.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Protocol for formatting output in different formats -public protocol OutputFormatter: Sendable { - /// Format an encodable value to a string - func format(_ value: T) throws -> String -} - -/// Supported output formats -public enum OutputFormat: String, Sendable, CaseIterable { - case json - case table - case csv - case yaml - - // MARK: Public - - /// Create the appropriate formatter for this format - /// - Parameter pretty: Whether to use pretty printing (applies to JSON) - /// - Returns: A formatter configured for this format - public func createFormatter(pretty: Bool = false) -> any OutputFormatter { - OutputFormatterFactory.formatter(for: self, pretty: pretty) - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Implementations.swift b/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Implementations.swift deleted file mode 100644 index 843b074f..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Implementations.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// OutputFormatting+Implementations.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import MistKit - -// MARK: - Format-specific implementations - -extension OutputFormatting { - /// Output results in JSON format - func outputJSON(_ results: [T]) async throws { - let jsonData: Data - if results.count == 1 { - jsonData = try JSONEncoder().encode(results[0]) - } else { - jsonData = try JSONEncoder().encode(results) - } - - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw OutputFormattingError.encodingFailure("Failed to encode JSON") - } - - print(jsonString) - } - - /// Output results in table format - func outputTable(_ results: [T]) async throws { - if results.isEmpty { - print(MistDemoConstants.Messages.noRecordsFound) - return - } - - // Table output is type-specific, so we need to handle known types - if let records = results as? [RecordInfo] { - try await outputRecordTable(records) - } else if let userInfo = results.first as? UserInfo, results.count == 1 { - try await outputUserTable(userInfo) - } else { - // Fall back to JSON for unknown types - try await outputJSON(results) - } - } - - /// Output results in CSV format - func outputCSV(_ results: [T]) async throws { - // CSV output is type-specific, so we need to handle known types - if let records = results as? [RecordInfo] { - try await outputRecordCSV(records) - } else if let userInfo = results.first as? UserInfo, results.count == 1 { - try await outputUserCSV([userInfo]) - } else { - // Fall back to JSON for unknown types - try await outputJSON(results) - } - } - - /// Output results in YAML format - func outputYAML(_ results: [T]) async throws { - // YAML output is type-specific, so we need to handle known types - if let records = results as? [RecordInfo] { - try await outputRecordYAML(records) - } else if let userInfo = results.first as? UserInfo, results.count == 1 { - try await outputUserYAML(userInfo) - } else { - // Fall back to JSON for unknown types - try await outputJSON(results) - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Records.swift b/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Records.swift deleted file mode 100644 index a1dc76a6..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Records.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// OutputFormatting+Records.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import MistKit - -// MARK: - RecordInfo Output Formatting - -extension OutputFormatting { - /// Output RecordInfo results in table format - func outputRecordTable(_ records: [RecordInfo], fields: [String]? = nil) async throws { - if records.isEmpty { - print(MistDemoConstants.Messages.noRecordsFound) - return - } - - if records.count == 1 { - // Single record - detailed view - let record = records[0] - print(MistDemoConstants.Messages.recordCreated) - print("├─ Name: \(record.recordName)") - print("├─ Type: \(record.recordType)") - if let changeTag = record.recordChangeTag { - print("├─ Change Tag: \(changeTag)") - } - print("└─ Fields:") - - let fieldsToShow = filterFields(record.fields, fields: fields) - for (fieldName, fieldValue) in fieldsToShow { - let formattedValue = FieldValueFormatter.formatFieldValue(fieldValue) - print(" ├─ \(fieldName): \(formattedValue)") - } - } else { - // Multiple records - list view - print("Found \(records.count) record(s):") - print(String(repeating: "=", count: 50)) - - for (index, record) in records.enumerated() { - print("\n[\(index + 1)] Record: \(record.recordName)") - print(" Type: \(record.recordType)") - if let changeTag = record.recordChangeTag { - print(" Change Tag: \(changeTag)") - } - print(" Fields:") - - let fieldsToShow = filterFields(record.fields, fields: fields) - for (fieldName, fieldValue) in fieldsToShow { - let formattedValue = FieldValueFormatter.formatFieldValue(fieldValue) - print(" \(fieldName): \(formattedValue)") - } - } - } - } - - /// Output RecordInfo results in CSV format - func outputRecordCSV(_ records: [RecordInfo], fields: [String]? = nil) async throws { - // Collect all unique field names (filtered if requested) - let allFieldNames = Set(records.flatMap { record in - record.fields.keys.filter { fieldName in - shouldIncludeField(fieldName, fields: fields) - } - }) - - let sortedFieldNames = [ - MistDemoConstants.FieldNames.recordName, - MistDemoConstants.FieldNames.recordType, - MistDemoConstants.FieldNames.recordChangeTag - ].filter { shouldIncludeField($0, fields: fields) } + allFieldNames.sorted() - - // Print header - print(sortedFieldNames.joined(separator: ",")) - - // Print records - let csvEscaper = CSVEscaper() - for record in records { - var values: [String] = [] - for fieldName in sortedFieldNames { - switch fieldName { - case MistDemoConstants.FieldNames.recordName: - values.append(csvEscaper.escape(record.recordName)) - case MistDemoConstants.FieldNames.recordType: - values.append(csvEscaper.escape(record.recordType)) - case MistDemoConstants.FieldNames.recordChangeTag: - values.append(csvEscaper.escape(record.recordChangeTag ?? "")) - default: - if let fieldValue = record.fields[fieldName] { - let formatted = FieldValueFormatter.formatFieldValue(fieldValue) - values.append(csvEscaper.escape(formatted)) - } else { - values.append("") - } - } - } - print(values.joined(separator: ",")) - } - } - - /// Output RecordInfo results in YAML format - func outputRecordYAML(_ records: [RecordInfo], fields: [String]? = nil) async throws { - let yamlEscaper = YAMLEscaper() - if records.count == 1 { - let record = records[0] - print("record:") - print(" \(MistDemoConstants.FieldNames.recordName): \(yamlEscaper.escape(record.recordName))") - print(" \(MistDemoConstants.FieldNames.recordType): \(yamlEscaper.escape(record.recordType))") - if let changeTag = record.recordChangeTag { - print(" \(MistDemoConstants.FieldNames.recordChangeTag): \(yamlEscaper.escape(changeTag))") - } - print(" fields:") - - let fieldsToShow = filterFields(record.fields, fields: fields) - for (fieldName, fieldValue) in fieldsToShow { - let formatted = FieldValueFormatter.formatFieldValue(fieldValue) - print(" \(fieldName): \(yamlEscaper.escape(formatted))") - } - } else { - print("records:") - for record in records { - print(" - \(MistDemoConstants.FieldNames.recordName): \(yamlEscaper.escape(record.recordName))") - print(" \(MistDemoConstants.FieldNames.recordType): \(yamlEscaper.escape(record.recordType))") - if let changeTag = record.recordChangeTag { - print(" \(MistDemoConstants.FieldNames.recordChangeTag): \(yamlEscaper.escape(changeTag))") - } - print(" fields:") - - let fieldsToShow = filterFields(record.fields, fields: fields) - for (fieldName, fieldValue) in fieldsToShow { - let formatted = FieldValueFormatter.formatFieldValue(fieldValue) - print(" \(fieldName): \(yamlEscaper.escape(formatted))") - } - } - } - } - - // MARK: - Helper Methods - - /// Filter fields based on the fields parameter - private func filterFields(_ fields: [String: FieldValue], fields fieldsFilter: [String]?) -> [String: FieldValue] { - guard let fieldsFilter = fieldsFilter, !fieldsFilter.isEmpty else { - return fields - } - - return fields.filter { fieldName, _ in - shouldIncludeField(fieldName, fields: fieldsFilter) - } - } - - /// Check if a field should be included based on field filter - private func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { - guard let fields = fields, !fields.isEmpty else { - return true // Include all fields if no filter specified - } - - return fields.contains { requestedField in - fieldName.lowercased() == requestedField.lowercased() - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Users.swift b/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Users.swift deleted file mode 100644 index 06848e73..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting+Users.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// OutputFormatting+Users.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import MistKit - -// MARK: - UserInfo Output Formatting - -extension OutputFormatting { - /// Output UserInfo result in table format - func outputUserTable(_ userInfo: UserInfo, fields: [String]? = nil) async throws { - print("User Information:") - print("├─ User Record Name: \(userInfo.userRecordName)") - - if shouldIncludeUserField("firstName", fields: fields), let firstName = userInfo.firstName { - print("├─ First Name: \(firstName)") - } - - if shouldIncludeUserField("lastName", fields: fields), let lastName = userInfo.lastName { - print("├─ Last Name: \(lastName)") - } - - if shouldIncludeUserField("emailAddress", fields: fields), let email = userInfo.emailAddress { - print("└─ Email: \(email)") - } else { - // Adjust the last character if email is not shown - print("") // Just end the tree properly - } - } - - /// Output UserInfo results in CSV format - func outputUserCSV(_ users: [UserInfo], fields: [String]? = nil) async throws { - // Build header based on available fields - var headers: [String] = ["userRecordName"] - - if shouldIncludeUserField("firstName", fields: fields) { - headers.append("firstName") - } - if shouldIncludeUserField("lastName", fields: fields) { - headers.append("lastName") - } - if shouldIncludeUserField("emailAddress", fields: fields) { - headers.append("emailAddress") - } - - print(headers.joined(separator: ",")) - - // Output user data - let csvEscaper = CSVEscaper() - for user in users { - var values: [String] = [csvEscaper.escape(user.userRecordName)] - - if shouldIncludeUserField("firstName", fields: fields) { - values.append(csvEscaper.escape(user.firstName ?? "")) - } - if shouldIncludeUserField("lastName", fields: fields) { - values.append(csvEscaper.escape(user.lastName ?? "")) - } - if shouldIncludeUserField("emailAddress", fields: fields) { - values.append(csvEscaper.escape(user.emailAddress ?? "")) - } - - print(values.joined(separator: ",")) - } - } - - /// Output UserInfo result in YAML format - func outputUserYAML(_ userInfo: UserInfo, fields: [String]? = nil) async throws { - let yamlEscaper = YAMLEscaper() - print("user:") - print(" userRecordName: \(yamlEscaper.escape(userInfo.userRecordName))") - - if shouldIncludeUserField("firstName", fields: fields), let firstName = userInfo.firstName { - print(" firstName: \(yamlEscaper.escape(firstName))") - } - - if shouldIncludeUserField("lastName", fields: fields), let lastName = userInfo.lastName { - print(" lastName: \(yamlEscaper.escape(lastName))") - } - - if shouldIncludeUserField("emailAddress", fields: fields), let email = userInfo.emailAddress { - print(" emailAddress: \(yamlEscaper.escape(email))") - } - } - - // MARK: - Helper Methods - - /// Check if a user field should be included based on field filter - private func shouldIncludeUserField(_ fieldName: String, fields: [String]?) -> Bool { - guard let fields = fields, !fields.isEmpty else { - return true // Include all fields if no filter specified - } - - return fields.contains { requestedField in - fieldName.lowercased() == requestedField.lowercased() - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting.swift b/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting.swift deleted file mode 100644 index c5157b6b..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Protocols/OutputFormatting.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// OutputFormatting.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import MistKit - -/// Protocol for formatting command output in different formats -public protocol OutputFormatting { - /// Output a single result in the specified format - func outputResult(_ result: T, format: OutputFormat) async throws - - /// Output multiple results in the specified format - func outputResults(_ results: [T], format: OutputFormat) async throws -} - -public extension OutputFormatting { - /// Default implementation for outputting a single result - func outputResult(_ result: T, format: OutputFormat) async throws { - try await outputResults([result], format: format) - } - - /// Default implementation for outputting multiple results - func outputResults(_ results: [T], format: OutputFormat) async throws { - switch format { - case .json: - try await outputJSON(results) - case .table: - try await outputTable(results) - case .csv: - try await outputCSV(results) - case .yaml: - try await outputYAML(results) - } - } -} - diff --git a/Examples/MistDemo/Sources/MistDemo/Resources/index.html b/Examples/MistDemo/Sources/MistDemo/Resources/index.html deleted file mode 100644 index 114e1593..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Resources/index.html +++ /dev/null @@ -1,651 +0,0 @@ - - - - - - MistKit CloudKit Authentication Example - - - - -
-

MistKit CloudKit Example

-

Sign in with your Apple ID to test CloudKit Web Services authentication and API access.

- -
- - -
Authenticating...
-
-
-
- - - - diff --git a/Examples/MistDemo/Sources/MistDemo/Types/AnyCodable.swift b/Examples/MistDemo/Sources/MistDemo/Types/AnyCodable.swift deleted file mode 100644 index 03d5b294..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Types/AnyCodable.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// AnyCodable.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Helper for decoding arbitrary JSON values -struct AnyCodable: Codable { - let value: Any - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let stringValue = try? container.decode(String.self) { - value = stringValue - } else if let intValue = try? container.decode(Int.self) { - value = intValue - } else if let doubleValue = try? container.decode(Double.self) { - value = doubleValue - } else if let boolValue = try? container.decode(Bool.self) { - value = boolValue - } else if container.decodeNil() { - value = NSNull() - } else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unable to decode value" - ) - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - switch value { - case let stringValue as String: - try container.encode(stringValue) - case let intValue as Int: - try container.encode(intValue) - case let doubleValue as Double: - try container.encode(doubleValue) - case let boolValue as Bool: - try container.encode(boolValue) - case is NSNull: - try container.encodeNil() - default: - throw EncodingError.invalidValue( - value, - EncodingError.Context( - codingPath: encoder.codingPath, - debugDescription: "Unable to encode value of type \(type(of: value))" - ) - ) - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Types/FieldInputValue.swift b/Examples/MistDemo/Sources/MistDemo/Types/FieldInputValue.swift deleted file mode 100644 index a65c62ba..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Types/FieldInputValue.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// FieldInputValue.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Enum representing different types of field input values -public enum FieldInputValue { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case asset(String) // Asset URL from upload token - - /// Convert to FieldType and string value for Field creation - func toFieldComponents() throws -> (FieldType, String) { - switch self { - case .string(let value): - return (.string, value) - case .int(let value): - return (.int64, String(value)) - case .double(let value): - return (.double, String(value)) - case .bool(let value): - return (.string, value ? "true" : "false") - case .asset(let url): - return (.asset, url) - } - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Types/FieldsInput.swift b/Examples/MistDemo/Sources/MistDemo/Types/FieldsInput.swift deleted file mode 100644 index 6caf3d80..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Types/FieldsInput.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// FieldsInput.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Type-safe representation of field input from JSON -public struct FieldsInput: Codable { - private let storage: [String: FieldInputValue] - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DynamicKey.self) - var fields: [String: FieldInputValue] = [:] - - for key in container.allKeys { - if let stringValue = try? container.decode(String.self, forKey: key) { - fields[key.stringValue] = .string(stringValue) - } else if let intValue = try? container.decode(Int.self, forKey: key) { - fields[key.stringValue] = .int(intValue) - } else if let doubleValue = try? container.decode(Double.self, forKey: key) { - fields[key.stringValue] = .double(doubleValue) - } else if let boolValue = try? container.decode(Bool.self, forKey: key) { - fields[key.stringValue] = .bool(boolValue) - } else { - // Try to decode as a generic JSON value and convert to string - let jsonValue = try container.decode(AnyCodable.self, forKey: key) - fields[key.stringValue] = .string(String(describing: jsonValue.value)) - } - } - - self.storage = fields - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: DynamicKey.self) - - for (key, value) in storage { - let dynamicKey = DynamicKey(stringValue: key)! - switch value { - case .string(let stringValue): - try container.encode(stringValue, forKey: dynamicKey) - case .int(let intValue): - try container.encode(intValue, forKey: dynamicKey) - case .double(let doubleValue): - try container.encode(doubleValue, forKey: dynamicKey) - case .bool(let boolValue): - try container.encode(boolValue, forKey: dynamicKey) - case .asset(let url): - try container.encode(url, forKey: dynamicKey) - } - } - } - - /// Convert to Field array for CloudKit processing - public func toFields() throws -> [Field] { - return try storage.map { (name, value) in - let (fieldType, stringValue) = try value.toFieldComponents() - return Field(name: name, type: fieldType, value: stringValue) - } - } -} - diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/AsyncChannel.swift b/Examples/MistDemo/Sources/MistDemo/Utilities/AsyncChannel.swift deleted file mode 100644 index 740f110f..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/AsyncChannel.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// AsyncChannel.swift -// MistDemo -// -// Created by Leo Dion on 7/9/25. -// - -public import Foundation - -/// AsyncChannel for communication between server and main thread -actor AsyncChannel { - private var value: T? - private var continuation: CheckedContinuation? - - func send(_ newValue: T) { - if let continuation = continuation { - continuation.resume(returning: newValue) - self.continuation = nil - } else { - value = newValue - } - } - - func receive() async -> T { - if let value = value { - self.value = nil - return value - } - - return await withCheckedContinuation { continuation in - self.continuation = continuation - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/AsyncHelpers.swift b/Examples/MistDemo/Sources/MistDemo/Utilities/AsyncHelpers.swift deleted file mode 100644 index e76591f8..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/AsyncHelpers.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// AsyncHelpers.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import UnixSignals - -/// Timeout error for async operations -public enum AsyncTimeoutError: Error, LocalizedError { - case timeout(String) - case cancelled(String) - - public var errorDescription: String? { - switch self { - case .timeout(let message): - return "Operation timed out: \(message)" - case .cancelled(let message): - return "Operation cancelled: \(message)" - } - } -} - -/// Execute an async operation with a timeout -public func withTimeout( - seconds: Double, - operation: @escaping @Sendable () async throws -> T -) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - return try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw AsyncTimeoutError.timeout("Operation timed out after \(seconds) seconds") - } - - guard let result = try await group.next() else { - throw AsyncTimeoutError.timeout("Timeout task failed") - } - - group.cancelAll() - return result - } -} - -/// Execute an async operation with signal handling (Ctrl+C, SIGTERM) -public func withSignalHandling( - operation: @escaping @Sendable () async throws -> T -) async throws -> T { - #if os(Linux) || os(macOS) - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - return try await operation() - } - - group.addTask { - let signals = await UnixSignalsSequence(trapping: [.sigint, .sigterm]) - for try await signal in signals { - print("\n⚠️ Received signal: \(signal)") - throw AsyncTimeoutError.cancelled("Operation cancelled by signal") - } - throw AsyncTimeoutError.cancelled("Signal handler completed unexpectedly") - } - - guard let result = try await group.next() else { - throw AsyncTimeoutError.cancelled("Task group completed without result") - } - - group.cancelAll() - return result - } - #else - return try await operation() - #endif -} - -/// Execute an async operation with both timeout and signal handling -public func withTimeoutAndSignals( - seconds: Double, - operation: @escaping @Sendable () async throws -> T -) async throws -> T { - try await withSignalHandling { - try await withTimeout(seconds: seconds, operation: operation) - } -} - -/// Format a timeout duration for user display -public func formatTimeout(_ seconds: Double) -> String { - if seconds < 60 { - return "\(Int(seconds)) seconds" - } else { - let minutes = Int(seconds / 60) - return "\(minutes) minute\(minutes == 1 ? "" : "s")" - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationHelper.swift b/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationHelper.swift deleted file mode 100644 index 3253fa76..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationHelper.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// AuthenticationHelper.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Helper utilities for managing CloudKit authentication -enum AuthenticationHelper { - /// Creates appropriate TokenManager and determines database based on credentials - /// - Parameters: - /// - apiToken: CloudKit API token (always required) - /// - webAuthToken: Web authentication token from Sign in with Apple - /// - keyID: Server-to-server key identifier - /// - privateKey: Server-to-server private key as string - /// - privateKeyFile: Path to server-to-server private key file - /// - databaseOverride: Optional database override ("public" or "private") - /// - Returns: Authentication result with TokenManager and selected database - /// - Throws: Error if credentials are invalid or missing - static func setupAuthentication( - apiToken: String, - webAuthToken: String?, - keyID: String?, - privateKey: String?, - privateKeyFile: String?, - databaseOverride: String? = nil - ) async throws -> AuthenticationResult { - - // Check for server-to-server authentication - if let keyID = keyID { - // Server-to-server always uses public database - let database = MistKit.Database.public - - // Check for invalid override - if let override = databaseOverride, override == "private" { - throw AuthenticationError.serverToServerRequiresPublicDatabase - } - - let manager = try await createServerToServerManager( - keyID: keyID, - privateKey: privateKey, - privateKeyFile: privateKeyFile - ) - - return AuthenticationResult( - tokenManager: manager, - database: database, - authMethod: "🔐 Server-to-server authentication (public database only)" - ) - } - - // Web authentication - if let webAuthToken = webAuthToken, !webAuthToken.isEmpty { - // With web auth token, default to private but allow override - let database: MistKit.Database - if let override = databaseOverride { - database = override == "public" ? .public : .private - } else { - database = .private // Default to private when web auth is available - } - - let manager = try await createWebAuthManager( - apiToken: apiToken, - webAuthToken: webAuthToken - ) - - return AuthenticationResult( - tokenManager: manager, - database: database, - authMethod: "🌐 Web authentication (\(database) database)" - ) - } - - // API-only authentication (no web token) - // Can only use public database - let database = MistKit.Database.public - - // Check for invalid override - if let override = databaseOverride, override == "private" { - throw AuthenticationError.privateRequiresWebAuth - } - - let manager = APITokenManager(apiToken: apiToken) - - // Validate credentials - let isValid = try await manager.validateCredentials() - guard isValid else { - throw AuthenticationError.invalidAPIToken - } - - return AuthenticationResult( - tokenManager: manager, - database: database, - authMethod: "🔑 API-only authentication (public database only)" - ) - } - - /// Creates a ServerToServerAuthManager - private static func createServerToServerManager( - keyID: String, - privateKey: String?, - privateKeyFile: String? - ) async throws -> any TokenManager { - - // Get the private key PEM string - let privateKeyPEM: String - if let keyFile = privateKeyFile { - do { - privateKeyPEM = try String(contentsOfFile: keyFile, encoding: .utf8) - } catch { - throw AuthenticationError.failedToReadPrivateKeyFile( - path: keyFile, - errorDescription: error.localizedDescription - ) - } - } else if let key = privateKey { - privateKeyPEM = key - } else { - throw AuthenticationError.missingPrivateKey - } - - // Check platform availability - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - throw AuthenticationError.serverToServerNotSupported - } - - // Create and validate the manager - let manager = try ServerToServerAuthManager( - keyID: keyID, - pemString: privateKeyPEM - ) - - // Validate credentials - let isValid = try await manager.validateCredentials() - guard isValid else { - throw AuthenticationError.invalidServerToServerCredentials - } - - return manager - } - - /// Creates a WebAuthTokenManager - private static func createWebAuthManager( - apiToken: String, - webAuthToken: String - ) async throws -> any TokenManager { - let manager = WebAuthTokenManager( - apiToken: apiToken, - webAuthToken: webAuthToken - ) - - // Validate credentials - let isValid = try await manager.validateCredentials() - guard isValid else { - throw AuthenticationError.invalidWebAuthCredentials - } - - return manager - } - - /// Resolves API token from option or environment variable - static func resolveAPIToken(_ apiToken: String) -> String { - apiToken.isEmpty ? - EnvironmentConfig.getOptional(EnvironmentConfig.Keys.cloudKitAPIToken) ?? "" : - apiToken - } - - /// Resolves web auth token from option or environment variable - static func resolveWebAuthToken(_ webAuthToken: String) -> String? { - let token = webAuthToken.isEmpty ? - EnvironmentConfig.getOptional(MistDemoConstants.EnvironmentVars.cloudKitWebAuthToken) ?? "" : - webAuthToken - return token.isEmpty ? nil : token - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/BrowserOpener.swift b/Examples/MistDemo/Sources/MistDemo/Utilities/BrowserOpener.swift deleted file mode 100644 index 94a0707c..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/BrowserOpener.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// BrowserOpener.swift -// MistDemo -// -// Created by Leo Dion on 7/9/25. -// - -public import Foundation -#if canImport(AppKit) -import AppKit -#endif - -/// Utility for opening URLs in the default browser -struct BrowserOpener { - - /// Open a URL in the default browser - /// - Parameter url: The URL string to open - static func openBrowser(url: String) { - #if canImport(AppKit) - if let url = URL(string: url) { - NSWorkspace.shared.open(url) - } - #elseif os(Linux) - let process = Process() - process.launchPath = "/usr/bin/env" - process.arguments = ["xdg-open", url] - try? process.run() - #endif - } -} diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/FieldValueFormatter.swift b/Examples/MistDemo/Sources/MistDemo/Utilities/FieldValueFormatter.swift deleted file mode 100644 index 12abd531..00000000 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/FieldValueFormatter.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// FieldValueFormatter.swift -// MistDemo -// -// Created by Leo Dion on 7/9/25. -// - -import Foundation -import MistKit - -/// Utility for formatting FieldValue objects for display -struct FieldValueFormatter { - - /// Format FieldValue fields for display - static func formatFields(_ fields: [String: FieldValue]) -> String { - if fields.isEmpty { - return "{}" - } - - let formattedFields = fields.map { (key, value) in - let valueString = formatFieldValue(value) - return "\(key): \(valueString)" - }.joined(separator: ", ") - - return "{\(formattedFields)}" - } - - /// Extract the raw display string from a FieldValue without extra quoting. - /// Used by formatters where the escaper handles quoting. - static func displayString(_ value: FieldValue) -> String { - switch value { - case .string(let string): - return string - case .int64(let int): - return "\(int)" - case .double(let double): - return "\(double)" - case .bytes(let bytes): - return bytes - case .date(let date): - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter.string(from: date) - case .location(let location): - return "(\(location.latitude), \(location.longitude))" - case .reference(let reference): - return reference.recordName - case .asset(let asset): - return asset.downloadURL ?? "no URL" - case .list(let values): - let formattedValues = values.map { displayString($0) }.joined(separator: ", ") - return "[\(formattedValues)]" - } - } - - /// Format a single FieldValue for display - static func formatFieldValue(_ value: FieldValue) -> String { - switch value { - case .string(let string): - return "\"\(string)\"" - case .int64(let int): - return "\(int)" - case .double(let double): - return "\(double)" - case .bytes(let bytes): - return "bytes(\(bytes.count) chars, base64: \(bytes))" - case .date(let date): - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return "date(\(formatter.string(from: date)))" - case .location(let location): - return "location(\(location.latitude), \(location.longitude))" - case .reference(let reference): - return "reference(\(reference.recordName))" - case .asset(let asset): - return "asset(\(asset.downloadURL ?? "no URL"))" - case .list(let values): - let formattedValues = values.map { formatFieldValue($0) }.joined(separator: ", ") - return "[\(formattedValues)]" - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift new file mode 100644 index 00000000..6a099e18 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift @@ -0,0 +1,60 @@ +// +// CKRecord+TypedField.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import CloudKit + import Foundation + + extension CKRecord { + /// Reads `field` from the record and casts it to `T`. + /// + /// Returns `nil` when the field is absent — that's a normal optional + /// field. When the field is present but holds a value of the wrong + /// type, this triggers `assertionFailure` (debug-only crash) before + /// returning `nil`. A type mismatch indicates a schema/code drift + /// that should be caught loudly during development. + internal func typedValue( + forField field: String, + as _: T.Type = T.self + ) -> T? { + guard let raw = self[field] else { + return nil + } + guard let typed = raw as? T else { + assertionFailure( + "CKRecord field '\(field)' on record type '\(recordType)' " + + "expected \(T.self) but got \(Swift.type(of: raw)) " + + "(value: \(raw))" + ) + return nil + } + return typed + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift new file mode 100644 index 00000000..1961a64a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -0,0 +1,63 @@ +// +// Note.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import CloudKit + import Foundation + import MistDemoKit + + /// Note record, mirroring the `Note` type defined in `schema.ckdb`: + /// + /// RECORD TYPE Note ( + /// "title" STRING QUERYABLE SORTABLE SEARCHABLE, + /// "index" INT64 QUERYABLE SORTABLE, + /// "image" ASSET + /// ); + /// + /// Created / modified timestamps come from CloudKit's system metadata + /// (`CKRecord.creationDate` / `.modificationDate`), so there's no need + /// for custom `createdAt` / `modified` schema fields. + extension Note { + internal init?(_ record: CKRecord) { + guard record.recordType == Self.recordType else { + return nil + } + self.init( + id: record.recordID.recordName, + title: record.typedValue(forField: Fields.title, as: String.self), + index: record.typedValue(forField: Fields.index, as: NSNumber.self)?.int64Value, + imageAssetURL: record.typedValue(forField: Fields.image, as: CKAsset.self)?.fileURL, + modificationDate: record.modificationDate, + creationDate: record.creationDate, + recordChangeTag: record.recordChangeTag, + creatorUserRecordName: record.creatorUserRecordID?.recordName + ) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift new file mode 100644 index 00000000..ac711fc5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift @@ -0,0 +1,45 @@ +// +// ZoneRow.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import CloudKit + import Foundation + import MistDemoKit + + /// Display-friendly snapshot of a CKRecordZone for the SwiftUI list. + extension ZoneRow { + internal init(_ zone: CKRecordZone) { + self.init( + id: "\(zone.zoneID.zoneName)|\(zone.zoneID.ownerName)", + zoneName: zone.zoneID.zoneName, + ownerName: zone.zoneID.ownerName + ) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift new file mode 100644 index 00000000..d34a79ec --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift @@ -0,0 +1,52 @@ +// +// CKDatabase+WebAuthToken.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import CloudKit + + extension CKDatabase { + /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the + /// given CloudKit API token. Issues the same `158__…` value that + /// MistKit / `mistdemo auth-token` consume. + /// + /// `CKFetchWebAuthTokenOperation` must run against the private database + /// — running it on the public database fails or returns an unattributed + /// token. + internal func fetchWebAuthToken(apiToken: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) + operation.qualityOfService = .userInitiated + operation.fetchWebAuthTokenResultBlock = { @Sendable result in + continuation.resume(with: result) + } + add(operation) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift new file mode 100644 index 00000000..0be3b9b1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift @@ -0,0 +1,50 @@ +// +// CKDatabase.Scope+Demo.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import CloudKit + + extension CKDatabase.Scope { + /// Scopes exposed in the MistDemoApp picker. `.shared` is intentionally + /// excluded because the demo's `schema.ckdb` has no shared zones. + internal static let selectable: [CKDatabase.Scope] = [.public, .private] + + private static let labels: [CKDatabase.Scope: String] = [ + .public: "Public", + .private: "Private", + .shared: "Shared", + ] + + internal var label: String? { + let label = Self.labels[self] + assert(label != nil, "Unknown CKDatabase.Scope: \(self)") + return label + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift new file mode 100644 index 00000000..81c8e926 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -0,0 +1,199 @@ +// +// CloudKitStore.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import CloudKit + import Foundation + import MistDemoKit + public import Observation + + /// Observable source of truth for the MistDemo app's CloudKit state. + /// + /// Wraps `CKContainer`/`CKDatabase` directly. MistKit's REST surface is + /// reserved for server/Linux/WASI/Windows contexts where the CloudKit + /// framework isn't available. + @Observable + @MainActor + public final class CloudKitStore { + /// The shared demo container identifier — must match `MistDemoConfig.containerIdentifier`. + public static let demoContainerIdentifier = "iCloud.com.brightdigit.MistDemo" + + internal var accountStatus: CKAccountStatus = .couldNotDetermine + internal var lastError: String? + internal var databaseScope: CKDatabase.Scope = .private + + /// The signed-in iCloud user's record name. Mirrors `currentUserRecordName` + /// in the web demo and is used to flag the "You" badge on notes the + /// current user created. + internal var currentUserRecordName: String? + + internal let containerIdentifier: String + @ObservationIgnored private let container: CKContainer + + /// The CloudKit database for the current `databaseScope`. + internal var database: CKDatabase { container.database(with: databaseScope) } + + /// Creates a new service for the given CloudKit container. + /// - Parameter containerIdentifier: The CloudKit container identifier. + public init(containerIdentifier: String) { + self.containerIdentifier = containerIdentifier + self.container = CKContainer(identifier: containerIdentifier) + } + + /// Apply the editable fields onto a CKRecord. CloudKit's system metadata + /// (`creationDate`, `modificationDate`) is refreshed by the server on save, + /// so no manual timestamping is needed. + private static func apply( + title: String, index: Int64, imageURL: URL?, to record: CKRecord + ) { + record[Note.Fields.title] = title as NSString + record[Note.Fields.index] = NSNumber(value: index) + if let imageURL { + record[Note.Fields.image] = CKAsset(fileURL: imageURL) + } + } + + internal func refreshAccountStatus() async { + do { + let status = try await container.accountStatus() + self.accountStatus = status + } catch { + self.accountStatus = .couldNotDetermine + self.lastError = error.localizedDescription + } + if accountStatus == .available { + do { + let recordID = try await container.userRecordID() + self.currentUserRecordName = recordID.recordName + } catch { + self.currentUserRecordName = nil + self.lastError = error.localizedDescription + } + } else { + self.currentUserRecordName = nil + } + } + + /// List all record zones in the selected database (parity with `mistdemo lookup-zones`). + internal func loadZones() async throws -> [ZoneRow] { + let zones = try await database.allRecordZones() + return zones.map(ZoneRow.init).sorted { $0.zoneName < $1.zoneName } + } + + /// Query `Note` records from the selected database, newest first — + /// primary sort on creation date desc, modification date desc as the + /// tiebreaker. Matches the web demo's default sort. + /// Note's schema is defined in `schema.ckdb` (`___createTime` and + /// `___modTime` are both `SORTABLE`). + internal func queryNotes(limit: Int = 50) async throws -> [Note] { + let predicate = NSPredicate(value: true) + let query = CKQuery(recordType: Note.recordType, predicate: predicate) + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false), + NSSortDescriptor(key: "modificationDate", ascending: false), + ] + + let (matchResults, _) = try await database.records( + matching: query, + inZoneWith: nil, + desiredKeys: nil, + resultsLimit: limit + ) + + var notes: [Note] = [] + var failedCount = 0 + var firstFailure: (any Error)? + for (_, recordResult) in matchResults { + switch recordResult { + case .success(let record): + if let note = Note(record) { + notes.append(note) + } else { + failedCount += 1 + } + case .failure(let error): + failedCount += 1 + if firstFailure == nil { firstFailure = error } + } + } + + if failedCount > 0 { + let detail = firstFailure.map { ": \($0.localizedDescription)" } ?? "" + self.lastError = "Skipped \(failedCount) record(s)\(detail)" + } + + return notes + } + + // MARK: - Write operations (parity with `mistdemo create / update / delete`) + + /// Create a new Note in the selected database. + internal func createNote(title: String, index: Int64, imageURL: URL?) async throws -> Note { + let record = CKRecord(recordType: Note.recordType) + Self.apply(title: title, index: index, imageURL: imageURL, to: record) + let saved = try await database.save(record) + guard let note = Note(saved) else { + throw CloudKitStoreError.unexpectedSaveResult + } + return note + } + + /// Update an existing Note: fetch the underlying record by ID, apply the + /// new field values, and save. The fetch picks up the current change tag + /// so the save is rejected (rather than blindly clobbering) if the record + /// has been modified since the caller read it. + internal func updateNote( + _ existing: Note, title: String, index: Int64, imageURL: URL? + ) async throws -> Note { + let recordID = CKRecord.ID(recordName: existing.id) + let record = try await database.record(for: recordID) + Self.apply(title: title, index: index, imageURL: imageURL, to: record) + let saved = try await database.save(record) + guard let note = Note(saved) else { + throw CloudKitStoreError.unexpectedSaveResult + } + return note + } + + /// Delete a Note by record ID. + internal func deleteNote(_ note: Note) async throws { + _ = try await database.deleteRecord( + withID: CKRecord.ID(recordName: note.id) + ) + } + + /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the + /// given CloudKit API token. Always runs against the private database — + /// running the operation against the public database fails or returns + /// an unattributed token, regardless of the user's scope selection. + internal func fetchWebAuthToken(apiToken: String) async throws -> String { + try await container.privateCloudDatabase.fetchWebAuthToken(apiToken: apiToken) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift new file mode 100644 index 00000000..4826fb52 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -0,0 +1,45 @@ +// +// CloudKitStoreError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) + import Foundation + import MistDemoKit + + /// Errors specific to `CloudKitStore` operations. + internal enum CloudKitStoreError: Error, LocalizedError { + case unexpectedSaveResult + + internal var errorDescription: String? { + switch self { + case .unexpectedSaveResult: + return "CloudKit returned a record that couldn't be parsed as a Note." + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift new file mode 100644 index 00000000..b228da76 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift @@ -0,0 +1,78 @@ +// +// AccountView+Actions.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + import CloudKit + import SwiftUI + + #if canImport(AppKit) + import AppKit + #elseif canImport(UIKit) + import UIKit + #endif + + extension AccountView { + internal func seedTokenIfNeeded() { + guard apiToken.isEmpty else { + return + } + if let envValue = ProcessInfo.processInfo.environment[Self.envVarName], + !envValue.isEmpty, + !envValue.hasPrefix("${") + { + apiToken = envValue + tokenSource = .environment + } + } + + internal func fetchToken() async { + fetchingWebAuthToken = true + webAuthTokenError = nil + webAuthToken = nil + defer { fetchingWebAuthToken = false } + do { + let token = try await service.fetchWebAuthToken( + apiToken: apiToken.trimmingCharacters(in: .whitespacesAndNewlines) + ) + webAuthToken = token + } catch { + webAuthTokenError = error.localizedDescription + } + } + + internal func copy(_ value: String) { + #if canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #elseif canImport(UIKit) && !os(tvOS) && !os(watchOS) + UIPasteboard.general.string = value + #endif + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift new file mode 100644 index 00000000..8892fa41 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -0,0 +1,203 @@ +// +// AccountView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + import CloudKit + import SwiftUI + + #if canImport(AppKit) + import AppKit + #elseif canImport(UIKit) + import UIKit + #endif + + /// View showing the iCloud account status, the public/private database + /// selector, and a web-auth-token capture flow that mirrors + /// `mistdemo auth-token`. + internal struct AccountView: View { + /// Where the current `apiToken` value came from on this launch. + internal enum TokenSource { + case manual + case environment + } + + /// Env var name the MistDemo CLI also reads. + internal static let envVarName = "CLOUDKIT_API_TOKEN" + + @Environment(CloudKitStore.self) internal var service + @AppStorage("MistDemoApp.cloudKitApiToken") internal var apiToken: String = "" + @State internal var webAuthToken: String? + @State internal var fetchingWebAuthToken = false + @State internal var webAuthTokenError: String? + @State internal var tokenSource: TokenSource = .manual + + internal var body: some View { + @Bindable var bindable = service + Form { + Section("Container") { + LabeledContent("Container", value: service.containerIdentifier) + Picker("Database", selection: $bindable.databaseScope) { + ForEach(CKDatabase.Scope.selectable, id: \.self) { scope in + if let label = scope.label { + Text(label).tag(scope) + } + } + } + LabeledContent("iCloud Status", value: statusLabel) + } + webAuthTokenSection + if let error = service.lastError { + Section("Last Service Error") { + Text(error).font(.callout).foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + .navigationTitle("iCloud Account") + .toolbar { + ToolbarItem { + Button("Refresh") { + Task { await service.refreshAccountStatus() } + } + } + } + .onAppear { seedTokenIfNeeded() } + } + + private var sourceCaption: String? { + switch tokenSource { + case .manual: + return nil + case .environment: + return + "Loaded from $\(Self.envVarName) (xcodegen baked it into the scheme from .env)." + } + } + + private var statusLabel: String { + switch service.accountStatus { + case .available: return "Available" + case .noAccount: return "No iCloud Account" + case .restricted: return "Restricted" + case .couldNotDetermine: return "Could Not Determine" + case .temporarilyUnavailable: return "Temporarily Unavailable" + @unknown default: return "Unknown" + } + } + + private var webAuthTokenSection: some View { + Section { + tokenTextField + tokenActions + tokenDisplay + } header: { + Text("Web Auth Token") + } footer: { + Text( + "Issues the same `158__…` token that MistKit / " + + "`mistdemo auth-token` consume. " + + "Uses CKFetchWebAuthTokenOperation." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var tokenTextField: some View { + Group { + TextField( + "CloudKit API Token", + text: $apiToken, + prompt: Text("Paste from CloudKit Dashboard") + ) + #if !os(tvOS) && !os(watchOS) + .textFieldStyle(.roundedBorder) + #endif + .font(.body.monospaced()) + .onChange(of: apiToken) { _, _ in tokenSource = .manual } + #if os(iOS) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + #endif + if let caption = sourceCaption { + Text(caption).font(.caption).foregroundStyle(.secondary) + } + } + } + + private var tokenActions: some View { + HStack { + Button { + Task { await fetchToken() } + } label: { + if fetchingWebAuthToken { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Fetching…") + } + } else { + Text("Fetch Web Auth Token") + } + } + .buttonStyle(.borderedProminent) + .disabled(apiToken.isEmpty || fetchingWebAuthToken) + if webAuthToken != nil { + Button("Clear", role: .destructive) { + webAuthToken = nil + webAuthTokenError = nil + } + } + } + } + + @ViewBuilder + private var tokenDisplay: some View { + if let webAuthToken { + LabeledContent("Web Auth Token") { + VStack(alignment: .trailing, spacing: 6) { + Text(webAuthToken) + .font(.callout.monospaced()) + .lineLimit(3) + .truncationMode(.middle) + + #if !os(tvOS) && !os(watchOS) + .textSelection(.enabled) + #endif + Button("Copy") { copy(webAuthToken) } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if let webAuthTokenError { + Text(webAuthTokenError).font(.callout).foregroundStyle(.red) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AppMain.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AppMain.swift new file mode 100644 index 00000000..d49736a4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AppMain.swift @@ -0,0 +1,52 @@ +// +// AppMain.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + /// SwiftUI `App` entry point for the native MistDemo build. + /// + /// Concrete `@main` types in the demo target conform to `AppMain` to inherit + /// the standard window setup; the protocol exists so the scene wiring lives + /// in `MistDemoApp` rather than being duplicated in every executable target. + public protocol AppMain: App { + } + + extension AppMain { + /// Default scene: a single window hosting `RootView`. + public var body: some Scene { + WindowGroup("MistDemo (Native CloudKit)") { + RootView() + } + #if os(macOS) + .defaultSize(width: 880, height: 600) + #endif + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift new file mode 100644 index 00000000..dbea999a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift @@ -0,0 +1,54 @@ +// +// DetailColumnRoot.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import SwiftUI + + /// Routes the sidebar selection to the appropriate detail view. + internal struct DetailColumnRoot: View { + internal let selection: SidebarItem? + + internal var body: some View { + switch selection { + case .account: + AccountView() + case .zones: + ZoneListView() + case .query: + QueryView() + case nil: + ContentUnavailableView( + "Pick a section from the sidebar", + systemImage: "sidebar.left", + description: Text("Account, Zones, or Query Records") + ) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift new file mode 100644 index 00000000..19e74e1b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift @@ -0,0 +1,218 @@ +// +// NoteEditView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + import MistDemoKit + import SwiftUI + import UniformTypeIdentifiers + + /// Sheet form for creating or editing a Note. The same view backs both flows; + /// the `mode` value drives the title and which service method is called on save. + internal struct NoteEditView: View { + internal enum Mode { + case create + case edit(Note) + } + + internal let mode: Mode + internal let onSaved: (Note) -> Void + + @Environment(CloudKitStore.self) private var service + @Environment(\.dismiss) private var dismiss + + @State private var title: String = "" + @State private var indexText: String = "0" + @State private var imageURL: URL? + @State private var saving = false + @State private var saveError: String? + @State private var showFileImporter = false + + // Tracks the URL whose security-scoped access we currently hold so we can + // balance the start/stop calls across the view's lifetime — picking a + // different file, tapping Remove, or dismissing the sheet must all + // release the previous scope. + @State private var scopedURL: URL? + + private var navigationTitle: String { + switch mode { + case .create: + return "New Note" + case .edit: + return "Edit Note" + } + } + + private var isValid: Bool { + !title.trimmingCharacters(in: .whitespaces).isEmpty + && Int64(indexText) != nil + } + + internal var body: some View { + NavigationStack { + formContent + .formStyle(.grouped) + .navigationTitle(navigationTitle) + .toolbar { toolbarContent } + + #if !os(tvOS) && !os(watchOS) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.image], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + #endif + } + .onAppear { populateInitialState() } + .onDisappear { releaseScopedURL() } + .frame(minWidth: 420, minHeight: 360) + } + + @ViewBuilder private var formContent: some View { + Form { + Section("Note") { + TextField("Title", text: $title) + TextField("Index", text: $indexText) + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + + imageSection + + if let saveError { + Section("Error") { + Text(saveError).foregroundStyle(.red).font(.callout) + } + } + } + } + + @ViewBuilder private var imageSection: some View { + Section("Image (optional)") { + if let imageURL { + LabeledContent("File") { + Text(imageURL.lastPathComponent) + .lineLimit(1) + .truncationMode(.middle) + } + Button("Remove", role: .destructive) { + releaseScopedURL() + self.imageURL = nil + } + } + Button("Choose image…") { showFileImporter = true } + } + } + + @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(saving) + } + ToolbarItem(placement: .confirmationAction) { + if saving { + ProgressView().controlSize(.small) + } else { + Button("Save") { Task { await save() } } + .disabled(!isValid) + } + } + } + + private func handleFileImport(_ result: Result<[URL], any Error>) { + switch result { + case .success(let urls): + if let url = urls.first { + guard url.startAccessingSecurityScopedResource() else { + saveError = + "Couldn't access \(url.lastPathComponent) — file permissions denied." + return + } + // Release the previously-scoped URL before adopting the new one. + releaseScopedURL() + scopedURL = url + imageURL = url + } + case .failure(let error): + saveError = "Couldn't pick file: \(error.localizedDescription)" + } + } + + private func releaseScopedURL() { + scopedURL?.stopAccessingSecurityScopedResource() + scopedURL = nil + } + + private func populateInitialState() { + guard case .edit(let note) = mode else { + return + } + title = note.title ?? "" + indexText = note.index.map(String.init) ?? "0" + imageURL = note.imageAssetURL + } + + private func save() async { + saving = true + saveError = nil + defer { saving = false } + + guard let parsedIndex = Int64(indexText) else { + saveError = "Index must be an integer" + return + } + let trimmedTitle = title.trimmingCharacters(in: .whitespaces) + + do { + let note: Note + switch mode { + case .create: + note = try await service.createNote( + title: trimmedTitle, + index: parsedIndex, + imageURL: imageURL + ) + case .edit(let existing): + note = try await service.updateNote( + existing, + title: trimmedTitle, + index: parsedIndex, + imageURL: imageURL + ) + } + onSaved(note) + dismiss() + } catch { + saveError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift new file mode 100644 index 00000000..e5bdca8a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -0,0 +1,204 @@ +// +// QueryView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + import MistDemoKit + import SwiftUI + + /// View for querying Note records from CloudKit. + internal struct QueryView: View { + @Environment(CloudKitStore.self) private var service + @State private var limit: Int = 50 + @State private var notes: [Note] = [] + @State private var loading = false + @State private var loadError: String? + @State private var selectedNote: Note? + @State private var showCreateSheet = false + + internal var body: some View { + VStack(spacing: 0) { + controls + .padding() + Divider() + content + } + .navigationDestination(for: Note.self) { note in + RecordDetailView(note: note, onChange: { Task { await runQuery() } }) + } + .navigationTitle(service.databaseScope.label.map { "Notes — \($0)" } ?? "Notes") + .onChange(of: service.databaseScope) { _, _ in + notes = [] + Task { await runQuery() } + } + .toolbar { + ToolbarItem { + Button { + showCreateSheet = true + } label: { + Label("New Note", systemImage: "plus") + } + } + } + .sheet(isPresented: $showCreateSheet) { + NoteEditView(mode: .create) { _ in + Task { await runQuery() } + } + .environment(service) + } + } + + @ViewBuilder + private var content: some View { + if loading { + Spacer() + ProgressView("Querying \(Note.recordType)…") + Spacer() + } else if let loadError { + ContentUnavailableView( + "Query failed", + systemImage: "exclamationmark.triangle", + description: Text(loadError) + ) + } else if notes.isEmpty { + ContentUnavailableView( + "No notes", + systemImage: "tray", + description: Text( + "Tap + to create the first one, or run `mistdemo create` from the CLI." + ) + ) + } else { + notesList + } + } + + private var notesList: some View { + List(notes, selection: $selectedNote) { note in + NavigationLink(value: note) { + noteRow(note) + } + #if !os(tvOS) && !os(watchOS) + .swipeActions(edge: .trailing) { + Button("Delete", role: .destructive) { + Task { await delete(note) } + } + } + #endif + } + } + + private var controls: some View { + HStack(spacing: 12) { + Text("Type: \(Note.recordType)") + .font(.body.monospaced()) + .foregroundStyle(.secondary) + + #if !os(tvOS) && !os(watchOS) + Stepper(value: $limit, in: 1...200, step: 10) { + Text("Limit: \(limit)") + } + .frame(maxWidth: 200) + #endif + + Button("Run Query") { Task { await runQuery() } } + .buttonStyle(.borderedProminent) + } + } + + private func noteRow(_ note: Note) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(note.title ?? note.id).font(.body) + if isOwnedByCurrentUser(note) { + ownerBadge(creator: note.creatorUserRecordName) + } + } + HStack(spacing: 12) { + if let index = note.index { + Label("\(index)", systemImage: "number") + .font(.caption) + .foregroundStyle(.secondary) + } + if let creationDate = note.creationDate { + Label( + creationDate.formatted(date: .abbreviated, time: .omitted), + systemImage: "calendar" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + /// Mirrors the web demo's "You" badge — flag notes the signed-in user + /// created. CloudKit may stamp the creator as `__defaultOwner__` for + /// records the caller just created, so accept that sentinel as well. + private func isOwnedByCurrentUser(_ note: Note) -> Bool { + guard let creator = note.creatorUserRecordName else { + return false + } + if creator == "__defaultOwner__" { + return true + } + return creator == service.currentUserRecordName + } + + private func ownerBadge(creator: String?) -> some View { + Text("You") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.2), in: Capsule()) + .foregroundStyle(.green) + .accessibilityLabel("Created by you") + .help(creator.map { "Created by \($0)" } ?? "Created by you") + } + + private func runQuery() async { + loading = true + loadError = nil + defer { loading = false } + do { + notes = try await service.queryNotes(limit: limit) + } catch { + loadError = error.localizedDescription + } + } + + private func delete(_ note: Note) async { + do { + try await service.deleteNote(note) + notes.removeAll { $0.id == note.id } + } catch { + loadError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift new file mode 100644 index 00000000..e725b50e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -0,0 +1,162 @@ +// +// RecordDetailView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + import MistDemoKit + import SwiftUI + + /// Detail view showing all fields and metadata for a single Note record. + internal struct RecordDetailView: View { + @State internal var note: Note + internal let onChange: () -> Void + + @Environment(CloudKitStore.self) private var service + @Environment(\.dismiss) private var dismiss + + @State private var showEditSheet = false + @State private var showDeleteConfirmation = false + @State private var deleting = false + @State private var actionError: String? + + internal var body: some View { + Form { + identitySection + noteFieldsSection + assetSection + if let actionError { + Section("Error") { + Text(actionError).foregroundStyle(.red).font(.callout) + } + } + } + .formStyle(.grouped) + .navigationTitle(note.title ?? note.id) + .toolbar { + ToolbarItem { + Button { + showEditSheet = true + } label: { + Label("Edit", systemImage: "pencil") + } + } + ToolbarItem { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(deleting) + } + } + .sheet(isPresented: $showEditSheet) { + NoteEditView(mode: .edit(note)) { updated in + note = updated + onChange() + } + .environment(service) + } + .confirmationDialog( + "Delete \(note.title ?? note.id)?", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + Task { await delete() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently removes the record from CloudKit.") + } + } + + private var identitySection: some View { + Section("Identity") { + LabeledContent("Record Name", value: note.id) + LabeledContent("Record Type", value: Note.recordType) + if let recordChangeTag = note.recordChangeTag { + LabeledContent("Change Tag", value: recordChangeTag) + } + if let creationDate = note.creationDate { + LabeledContent( + "Created", + value: creationDate.formatted( + date: .abbreviated, time: .standard + ) + ) + } + if let modificationDate = note.modificationDate { + LabeledContent( + "Modified", + value: modificationDate.formatted( + date: .abbreviated, time: .standard + ) + ) + } + } + } + + private var noteFieldsSection: some View { + Section("Note Fields") { + LabeledContent("title", value: note.title ?? "—") + LabeledContent("index", value: note.index.map(String.init) ?? "—") + LabeledContent( + "image", + value: note.imageAssetURL?.lastPathComponent ?? "—" + ) + } + } + + @ViewBuilder + private var assetSection: some View { + if let url = note.imageAssetURL { + Section("Asset") { + AsyncImage(url: url) { image in + image.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + ProgressView() + } + .frame(maxHeight: 240) + } + } + } + + private func delete() async { + deleting = true + actionError = nil + defer { deleting = false } + do { + try await service.deleteNote(note) + onChange() + dismiss() + } catch { + actionError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift new file mode 100644 index 00000000..eea0f43a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift @@ -0,0 +1,63 @@ +// +// RootView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + public import SwiftUI + + /// Root view hosting the navigation split between sidebar and detail. + public struct RootView: View { + @State private var service = CloudKitStore( + containerIdentifier: CloudKitStore.demoContainerIdentifier + ) + + @State private var selection: SidebarItem? = .account + + /// The view body. + public var body: some View { + NavigationSplitView { + SidebarView(selection: $selection) + } detail: { + // The detail column needs its own NavigationStack so views like + // QueryView can push to RecordDetailView via NavigationLink(value:). + // Without this, NavigationLinks inside the detail column have no + // "next column" to target. + NavigationStack { + DetailColumnRoot(selection: selection) + } + } + .environment(service) + .task { + await service.refreshAccountStatus() + } + } + + /// Creates a new root view. + public init() {} + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift new file mode 100644 index 00000000..2d3a12ee --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift @@ -0,0 +1,51 @@ +// +// SidebarItem.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Sidebar navigation items for the MistDemo app. +internal enum SidebarItem: Hashable, CaseIterable { + case account + case zones + case query + + internal var label: String { + switch self { + case .account: return "iCloud Account" + case .zones: return "Zones" + case .query: return "Query Records" + } + } + + internal var systemImage: String { + switch self { + case .account: return "person.crop.circle" + case .zones: return "tray.full" + case .query: return "magnifyingglass" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift new file mode 100644 index 00000000..4c4906ab --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift @@ -0,0 +1,45 @@ +// +// SidebarView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import SwiftUI + + /// Sidebar list of navigation items. + internal struct SidebarView: View { + @Binding internal var selection: SidebarItem? + + internal var body: some View { + List(SidebarItem.allCases, id: \.self, selection: $selection) { item in + Label(item.label, systemImage: item.systemImage) + .tag(item) + } + .navigationTitle("MistDemo (Native)") + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift new file mode 100644 index 00000000..cb81d152 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift @@ -0,0 +1,95 @@ +// +// ZoneListView.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + import MistDemoKit + import SwiftUI + + /// View listing all CloudKit record zones. + internal struct ZoneListView: View { + @Environment(CloudKitStore.self) private var service + @State private var zones: [ZoneRow] = [] + @State private var loading = false + @State private var loadError: String? + + internal var body: some View { + Group { + if loading { + ProgressView("Loading zones…") + } else if let loadError { + ContentUnavailableView( + "Couldn't load zones", + systemImage: "exclamationmark.triangle", + description: Text(loadError) + ) + } else if zones.isEmpty { + ContentUnavailableView( + "No zones yet", + systemImage: "tray", + description: Text( + "Click Refresh to fetch zones from CloudKit." + ) + ) + } else { + List(zones) { zone in + VStack(alignment: .leading, spacing: 4) { + Text(zone.zoneName).font(.headline) + Text("Owner: \(zone.ownerName)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + .navigationTitle(service.databaseScope.label.map { "Zones — \($0)" } ?? "Zones") + .toolbar { + ToolbarItem { + Button("Refresh") { Task { await refresh() } } + } + } + .task { await refresh() } + .onChange(of: service.databaseScope) { _, _ in + zones = [] + Task { await refresh() } + } + } + + private func refresh() async { + loading = true + loadError = nil + defer { loading = false } + do { + zones = try await service.loadZones() + } catch { + loadError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift new file mode 100644 index 00000000..b3b6d955 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -0,0 +1,112 @@ +// +// MistKitClientFactory.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +public import MistKit + +/// Factory for creating MistKit `CloudKitService` instances from MistDemo +/// configuration. +public struct MistKitClientFactory: Sendable { + /// Create a `CloudKitService` configured for `config.database`, choosing + /// auth material automatically based on the populated environment. + /// + /// - `.public`: requires `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY[_FILE]`, + /// optionally augmented with `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` + /// so the same service can also satisfy user-identity routes. + /// - `.private` / `.shared`: requires `CLOUDKIT_API_TOKEN` + + /// `CLOUDKIT_WEB_AUTH_TOKEN`. The resulting web-auth credentials cover + /// user-identity routes too (which CloudKit pins to `.public`). + /// + /// The service is database-agnostic — operations pick their database at the + /// call site, and `Credentials` resolves the appropriate token manager per + /// call. A single returned service therefore covers every phase the + /// integration runner exercises, including the user-context routes that + /// previously required a second service. + /// + /// When `config.badCredentials == true`, this short-circuits and returns a + /// service backed by a deliberately invalid web-auth `TokenManager` so the + /// next CloudKit call yields a typed HTTP 401. Because that path always uses + /// web auth, it is **not** supported on `.public` and will throw + /// `ConfigurationError.badCredentialsOnPublicDB`. + /// + /// - Throws: `ConfigurationError` if required credentials are missing, or + /// if `badCredentials` is requested with `.public`. + public static func create( + for config: MistDemoConfig + ) throws -> CloudKitService { + #if os(WASI) + throw ConfigurationError.unsupportedPlatform( + "MistDemo CLI requires URLSession; WASI builds must inject a transport explicitly" + ) + #else + if config.badCredentials { + if case .public = config.database { + throw ConfigurationError.badCredentialsOnPublicDB + } + return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) + } + let credentials = try config.toPrimaryCredentials() + return CloudKitService( + containerIdentifier: config.containerIdentifier, + credentials: credentials, + environment: config.environment + ) + #endif + } + + /// Build a `WebAuthTokenManager` whose tokens pass `validateCredentials()`'s + /// local format check (64-char hex API token, ≥10-char web-auth token) but + /// are guaranteed to be rejected by Apple's servers, producing a real HTTP + /// 401. + internal static func makeBadCredentialsTokenManager() -> WebAuthTokenManager { + WebAuthTokenManager( + apiToken: String(repeating: "0", count: 64), + webAuthToken: String(repeating: "a", count: 100) + ) + } + + /// Create a `CloudKitService` with a caller-supplied `TokenManager`. Used + /// by the `--bad-credentials` demo path. + public static func create( + from config: MistDemoConfig, + tokenManager: any TokenManager + ) throws -> CloudKitService { + #if os(WASI) + throw ConfigurationError.unsupportedPlatform( + "MistDemo CLI requires URLSession; WASI builds must inject a transport explicitly" + ) + #else + return CloudKitService( + containerIdentifier: config.containerIdentifier, + tokenManager: tokenManager, + environment: config.environment + ) + #endif + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKitCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKitCommand.swift new file mode 100644 index 00000000..783a0404 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKitCommand.swift @@ -0,0 +1,54 @@ +// +// CloudKitCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +public import MistKit + +/// Protocol for commands that interact with CloudKit. +public protocol CloudKitCommand { + /// The CloudKit container identifier. + var containerIdentifier: String { get } + /// The CloudKit API token. + var apiToken: String { get } + /// The CloudKit environment (development or production). + var environment: String { get } +} + +extension CloudKitCommand { + /// Resolve API token from option or environment variable + public func resolvedApiToken() -> String { + apiToken.isEmpty + ? ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"] ?? "" : apiToken + } + + /// Convert environment string to MistKit Environment + public func cloudKitEnvironment() -> MistKit.Environment { + environment == "production" ? .production : .development + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift new file mode 100644 index 00000000..596e65fa --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift @@ -0,0 +1,148 @@ +// +// AuthTokenCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + /// Command to obtain web authentication token via browser flow. + public struct AuthTokenCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = AuthTokenConfig + + /// The command name. + public static let commandName = "auth-token" + /// The command abstract. + public static let abstract = + "Obtain a web authentication token via browser flow" + /// The command help text. + public static let helpText = """ + AUTH-TOKEN - Obtain web authentication token + + USAGE: + mistdemo auth-token [options] + + OPTIONS: + --api-token CloudKit API token + --environment development (default) | production + --port Server port (default: 8080) + --host Server host (default: 127.0.0.1) + --browser Open browser on startup (default for auth-token) + --no-browser Don't open browser on startup (overrides --browser) + """ + + internal let config: AuthTokenConfig + + /// Creates a new instance. + public init(config: AuthTokenConfig) { + self.config = config + } + + private static func captureToken( + runService: @escaping @Sendable () async throws -> Void, + tokenStore: WebAuthTokenStore, + host: String, + port: Int, + openBrowser: Bool + ) async throws -> String { + do { + return try await withTimeoutAndSignals(seconds: 300) { + try await withThrowingTaskGroup(of: String?.self) { group in + group.addTask { + try await runService() + return nil + } + group.addTask { + if openBrowser { + try? await Task.sleep(nanoseconds: 1_000_000_000) + BrowserOpener.openBrowser(url: "http://\(host):\(port)") + } + return nil + } + group.addTask { + var iterator = tokenStore.tokenUpdates.makeAsyncIterator() + return await iterator.next() + } + + while let result = try await group.next() { + if let captured = result { + group.cancelAll() + return captured + } + } + throw AuthTokenError.serverError( + "Token capture failed unexpectedly" + ) + } + } + } catch let error as AsyncTimeoutError { + throw AuthTokenError.timeout(error.localizedDescription) + } + } + + /// Executes the command. + public func execute() async throws { + print("📍 Server URL: http://\(config.host):\(config.port)") + + let tokenStore = WebAuthTokenStore() + let server = WebServer( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + publicDatabaseAvailable: false, + tokenStore: tokenStore, + backendFactory: .live( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment + ), + terminatesAfterAuth: true + ) + let app = Application( + router: try server.makeRouter(), + configuration: .init( + address: .hostname(config.host, port: config.port) + ) + ) + + let token = try await Self.captureToken( + runService: { try await app.runService() }, + tokenStore: tokenStore, + host: config.host, + port: config.port, + openBrowser: config.openBrowser + ) + + // Let the 205 response reach the browser before the process exits. + try? await Task.sleep(nanoseconds: 500_000_000) + print(token) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift new file mode 100644 index 00000000..cd31f8fd --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift @@ -0,0 +1,109 @@ +// +// CreateCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to create a new record in CloudKit +public struct CreateCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = CreateConfig + /// The command name. + public static let commandName = "create" + /// The command abstract. + public static let abstract = "Create a new record in CloudKit" + /// The command help text. + public static let helpText = """ + CREATE - Create a new record in CloudKit + + USAGE: + mistdemo create [options] + + OPTIONS: + --record-type Record type (default: Note) + --record-name Custom record name + --output-format Output format + + FIELD DEFINITION: + --field Inline field definition + --json-file Load fields from JSON + --stdin Read fields from stdin + + EXAMPLES: + mistdemo create --field "title:string:My Note" + mistdemo create --json-file fields.json + """ + + private let config: CreateConfig + + /// Creates a new instance. + public init(config: CreateConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + do { + // Create CloudKit client + let client = try MistKitClientFactory.create(for: config.base) + + // Generate record name if not provided + let recordName = config.recordName ?? generateRecordName() + + // Convert fields to CloudKit format + let cloudKitFields = try config.fields.toCloudKitFields() + + // Create the record + // NOTE: Zone support requires enhancements to CloudKitService.createRecord method + let recordInfo = try await client.createRecord( + recordType: config.recordType, + recordName: recordName, + fields: cloudKitFields, + // Zone: config.zone - to be added when CloudKitService supports it + database: config.base.database + ) + + // Format and output result + try await outputResult(recordInfo, format: config.output) + } catch { + throw CreateError.operationFailed(error.localizedDescription) + } + } + + /// Generate a unique record name + internal func generateRecordName() -> String { + let timestamp = Int(Date().timeIntervalSince1970) + let minSuffix = MistDemoConstants.Limits.randomSuffixMin + let maxSuffix = MistDemoConstants.Limits.randomSuffixMax + let randomSuffix = String(Int.random(in: minSuffix...maxSuffix)) + return "\(config.recordType.lowercased())-\(timestamp)-\(randomSuffix)" + } +} + +// CreateError is now defined in Errors/CreateError.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift new file mode 100644 index 00000000..6f02a7a4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift @@ -0,0 +1,83 @@ +// +// CurrentUserCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to get information about the authenticated user +public struct CurrentUserCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = CurrentUserConfig + /// The command name. + public static let commandName = "current-user" + /// The command abstract. + public static let abstract = "Get current user information" + /// The command help text. + public static let helpText = """ + CURRENT-USER - Get current user information + + USAGE: + mistdemo current-user [options] + + OPTIONS: + --api-token CloudKit API token + --web-auth-token Web auth token + --database Database to target + --fields Comma-separated fields + --output-format Output format + + NOTES: + - With --database public, requires server-to-server + credentials; --web-auth-token is ignored. + - With --database private or shared, + --web-auth-token is required. + """ + + private let config: CurrentUserConfig + + /// Creates a new instance. + public init(config: CurrentUserConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + do { + // Create CloudKit client + let client = try MistKitClientFactory.create(for: config.base) + + let userInfo = try await client.fetchCaller() + try await outputResult(userInfo, format: config.output) + } catch { + throw CurrentUserError.operationFailed(error.localizedDescription) + } + } +} + +// CurrentUserError is now defined in Errors/CurrentUserError.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift new file mode 100644 index 00000000..94bf05f3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift @@ -0,0 +1,117 @@ +// +// DeleteCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to delete an existing record from CloudKit +public struct DeleteCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = DeleteConfig + /// The command name. + public static let commandName = "delete" + /// The command abstract. + public static let abstract = "Delete an existing record from CloudKit" + /// The command help text. + public static let helpText = """ + DELETE - Delete an existing record from CloudKit + + USAGE: + mistdemo delete --record-name [options] + + REQUIRED: + --record-name Record name to delete + + OPTIONS: + --record-type Record type (default: Note) + --record-change-tag Optimistic locking tag + --force Ignore change-tag mismatch + --output-format Output format + + EXAMPLES: + mistdemo delete --record-name my-note-123 + mistdemo delete --record-name my-note-123 --force + """ + + private let config: DeleteConfig + + /// Creates a new instance. + public init(config: DeleteConfig) { + self.config = config + } + + internal static func mapConflict( + _ error: CloudKitError + ) -> DeleteError? { + guard error.httpStatusCode == 409 else { + return nil + } + if case .httpErrorWithDetails(_, _, let reason) = error { + return .conflict(reason: reason) + } + return .conflict(reason: nil) + } + + /// Executes the command. + public func execute() async throws { + do { + let client = try MistKitClientFactory.create( + for: config.base + ) + let effectiveChangeTag = + config.force ? nil : config.recordChangeTag + + try await client.deleteRecord( + recordType: config.recordType, + recordName: config.recordName, + recordChangeTag: effectiveChangeTag, + database: config.base.database + ) + + let result = DeleteResult( + recordName: config.recordName, + recordType: config.recordType + ) + try await outputResult(result, format: config.output) + } catch let error as DeleteError { + throw error + } catch let error as CloudKitError { + if let mapped = Self.mapConflict(error) { + throw mapped + } + throw DeleteError.operationFailed( + error.localizedDescription + ) + } catch { + throw DeleteError.operationFailed( + error.localizedDescription + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift new file mode 100644 index 00000000..6869050b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift @@ -0,0 +1,51 @@ +// +// DeleteResult.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Result of a successful delete, formatted as command output. +public struct DeleteResult: Encodable, Sendable { + /// The deleted record name. + public let recordName: String + /// The deleted record type. + public let recordType: String + /// Whether the record was deleted. + public let deleted: Bool + + /// Creates a new instance. + public init( + recordName: String, + recordType: String, + deleted: Bool = true + ) { + self.recordName = recordName + self.recordType = recordType + self.deleted = deleted + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift new file mode 100644 index 00000000..76cf3d35 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift @@ -0,0 +1,75 @@ +// +// DemoErrorsCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Walks the audience through CloudKit's typed errors for the talk's +/// "CloudKit as Your Backend" / Act 3, Step 4 — Error handling segment. +public struct DemoErrorsCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = DemoErrorsConfig + /// The command name. + public static let commandName = "demo-errors" + /// The command abstract. + public static let abstract = + "Demonstrate typed CloudKit error handling" + /// The command help text. + public static let helpText = """ + DEMO-ERRORS - Typed CloudKit error handling + + Triggers typed CloudKitError values for status codes + 401, 404, and 409. + + USAGE: + mistdemo demo-errors [--scenario ] + + OPTIONS: + --scenario all (default), 401, 404, 409 + --database Database for 404/409 demos + + NOTES: + The 401 scenario uses placeholder tokens. The 409 + scenario creates, mutates, then retries with a stale + recordChangeTag. + """ + + private let config: DemoErrorsConfig + + /// Creates a new instance. + public init(config: DemoErrorsConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + let runner = DemoErrorsRunner(config: config.base) + await runner.run(scenario: config.scenario) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift new file mode 100644 index 00000000..17fb2b2b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift @@ -0,0 +1,73 @@ +// +// DemoErrorsRunner+Output.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +extension DemoErrorsRunner { + internal func printRunnerHeader() { + print("\n" + String(repeating: "=", count: 80)) + print("🛑 CloudKit Error Demo — typed CloudKitError handling") + print(String(repeating: "=", count: 80)) + print("Container: \(config.containerIdentifier)") + print("Database: \(config.database.pathSegment)") + print(String(repeating: "=", count: 80)) + } + + internal func printRunnerFooter() { + print("\n" + String(repeating: "=", count: 80)) + print("✅ Error demo complete") + print(String(repeating: "=", count: 80)) + } + + internal func printSectionHeader(_ title: String) { + print("\n" + String(repeating: "-", count: 80)) + print("▶ \(title)") + print(String(repeating: "-", count: 80)) + } + + internal func printCloudKitError(_ error: CloudKitError, expectedStatus: Int) { + let status = error.httpStatusCode.map(String.init) ?? "n/a" + let prefix = error.httpStatusCode == expectedStatus ? "✅" : "❌" + print("\(prefix) Caught CloudKitError — status: \(status)") + if case .httpErrorWithDetails(_, let serverErrorCode, let reason) = error { + print(" serverErrorCode: \(serverErrorCode ?? "")") + print(" reason: \(reason ?? "")") + } else { + print(" detail: \(error.localizedDescription)") + } + } + + internal func describe(_ tag: String?) -> String { + guard let tag, !tag.isEmpty else { + return "" + } + return tag + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift new file mode 100644 index 00000000..ce259489 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift @@ -0,0 +1,212 @@ +// +// DemoErrorsRunner.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Runs the talk's CloudKit error scenarios (401, 404, 409) and prints typed +/// `CloudKitError` details. Mirrors the section/prefix style of +/// `IntegrationTestRunner`. +internal struct DemoErrorsRunner { + /// Record type used by 404 and 409 demos. The 404 type is unlikely to exist; + /// the 409 type is the same `Note` schema used by other MistDemo commands. + private static let bogusRecordType = + "DefinitelyNotARealType_DemoErrorsCommand_xyz" + private static let conflictRecordType = "Note" + + internal let config: MistDemoConfig + + internal func run(scenario: ErrorScenario) async { + printRunnerHeader() + switch scenario { + case .all: + await runUnauthorized() + await runNotFound() + await runConflict() + case .unauthorized: + await runUnauthorized() + case .notFound: + await runNotFound() + case .conflict: + await runConflict() + } + printRunnerFooter() + } + + // MARK: - 401 Unauthorized + + /// Demonstrates 401 by constructing a CloudKitService with deliberate placeholder + /// tokens. The user's real config is never mutated. The 401 demo is pinned to + /// `.private` because web-auth tokens are the most representative CloudKit + /// credential failure. + internal func runUnauthorized() async { + printSectionHeader("401 — Unauthorized (invalid credentials)") + do { + let badTokenManager = + MistKitClientFactory.makeBadCredentialsTokenManager() + let service = try MistKitClientFactory.create( + from: config.with(database: .private), + tokenManager: badTokenManager + ) + _ = try await service.fetchCaller() + print("⚠️ Expected 401 but call succeeded — credentials may not be validated server-side.") + } catch let error as CloudKitError { + printCloudKitError(error, expectedStatus: 401) + print( + "💡 Recovery: refresh CLOUDKIT_WEB_AUTH_TOKEN (or rerun `mistdemo auth-token`) and retry." + ) + } catch { + print("❌ Unexpected non-CloudKit error: \(error)") + } + } + + // MARK: - 404 Not Found + + /// Demonstrates 404 by querying a record type that doesn't exist in the schema. + internal func runNotFound() async { + printSectionHeader("404 — Not Found (unknown record type)") + do { + let service = try MistKitClientFactory.create(for: config) + _ = try await service.queryAllRecords(recordType: Self.bogusRecordType) + print("⚠️ Expected 404 but query returned successfully — schema may have changed.") + } catch let error as CloudKitError { + printCloudKitError(error, expectedStatus: 404) + print("💡 Recovery: handle the missing record (return empty / show empty state) and continue.") + } catch { + print("❌ Unexpected non-CloudKit error: \(error)") + } + } + + // MARK: - 409 Conflict + + /// Demonstrates 409 by creating a record, modifying it once + /// (which advances the `recordChangeTag`), then attempting a + /// second modify with the original (now stale) tag. + internal func runConflict() async { + printSectionHeader("409 — Conflict (stale recordChangeTag)") + + let service: CloudKitService + do { + service = try MistKitClientFactory.create(for: config) + } catch { + print("❌ Could not build service: \(error)") + return + } + + let recordName = + "demo-errors-conflict-\(Int(Date().timeIntervalSince1970))" + + let result = await setupAndRunConflict( + service: service, recordName: recordName + ) + await cleanupConflictRecord( + service: service, + createdRecordName: result + ) + } + + /// Creates a record, updates it to advance the change tag, + /// then retries with the stale tag to trigger 409. + private func setupAndRunConflict( + service: CloudKitService, + recordName: String + ) async -> String? { + var createdRecordName: String? + var staleTag: String? + + // Step 1: create the base record. + do { + let created = try await service.createRecord( + recordType: Self.conflictRecordType, + recordName: recordName, + fields: ["title": .string("original")], + database: config.database + ) + createdRecordName = created.recordName + staleTag = created.recordChangeTag + } catch { + print("❌ Setup create failed: \(error)") + return nil + } + + // Step 2: first update advances the changeTag. + do { + _ = try await service.updateRecord( + recordType: Self.conflictRecordType, + recordName: recordName, + fields: ["title": .string("first-update")], + recordChangeTag: staleTag, + database: config.database + ) + } catch { + print("❌ Setup update failed: \(error)") + return createdRecordName + } + + // Step 3: re-use the now-stale changeTag. + do { + _ = try await service.updateRecord( + recordType: Self.conflictRecordType, + recordName: recordName, + fields: ["title": .string("second-update-stale")], + recordChangeTag: staleTag, + database: config.database + ) + print("⚠️ Expected 409 but update was accepted.") + } catch { + printCloudKitError(error, expectedStatus: 409) + if error.httpStatusCode == 409 { + print("💡 Recovery: merge with serverRecord.") + } + } + + return createdRecordName + } + + /// Best-effort delete of the record created during the 409 demo. + private func cleanupConflictRecord( + service: CloudKitService, + createdRecordName: String? + ) async { + guard let createdRecordName else { + return + } + print("\n🧹 Cleaning up demo record \(createdRecordName)…") + do { + try await service.deleteRecord( + recordType: Self.conflictRecordType, + recordName: createdRecordName, + database: config.database + ) + print(" ✅ Deleted.") + } catch { + print(" ⚠️ Cleanup failed (non-fatal): \(error)") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift new file mode 100644 index 00000000..7c3a32e6 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift @@ -0,0 +1,178 @@ +// +// DemoInFilterCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Demonstrates the IN/NOT_IN QueryFilter fix (issue #192) end-to-end. +/// +/// The command: +/// 1. Creates three Note records with index values 10, 20, 30 +/// 2. Queries them back using QueryFilter.in("index", [10, 30]) +/// — expects exactly 2 results, confirming type-preserving serialization works +/// 3. Cleans up all three created records +public struct DemoInFilterCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = MistDemoConfig + /// The command name. + public static let commandName = "demo-in-filter" + /// The command abstract. + public static let abstract = + "Demonstrates IN/NOT_IN QueryFilter against CloudKit" + /// The command help text. + public static let helpText = """ + DEMO-IN-FILTER - IN/NOT_IN QueryFilter fix (issue #192) + + USAGE: + mistdemo demo-in-filter [options] + + DESCRIPTION: + Creates three Note records with index 10, 20, 30, + queries with IN filter for [10, 30], expects 2 results. + """ + + private let config: MistDemoConfig + + /// Creates a new instance. + public init(config: MistDemoConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + print("demo-in-filter requires macOS 11+ / iOS 14+") + return + } + + let client = try MistKitClientFactory.create(for: config) + let tag = Int(Date().timeIntervalSince1970) + let recordType = "Note" + + let createdNames = try await createDemoRecords( + client: client, recordType: recordType, tag: tag + ) + + try await verifyAndQueryRecords( + client: client, + recordType: recordType, + createdNames: createdNames + ) + + try await cleanupDemoRecords( + client: client, + recordType: recordType, + createdNames: createdNames + ) + } + + private func createDemoRecords( + client: CloudKitService, + recordType: String, + tag: Int + ) async throws -> [String] { + print("Creating 3 Note records with index 10, 20, 30...") + let indexValues: [Int] = [10, 20, 30] + var createdNames: [String] = [] + for idx in indexValues { + let record = try await client.createRecord( + recordType: recordType, + fields: [ + "title": .string("demo-in-filter-\(tag)-idx\(idx)"), + "index": .int64(idx), + ], + database: config.database + ) + createdNames.append(record.recordName) + print(" Created \(record.recordName) (index=\(idx))") + } + return createdNames + } + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + private func verifyAndQueryRecords( + client: CloudKitService, + recordType: String, + createdNames: [String] + ) async throws { + print("\nVerifying records are queryable...") + let allRecords = try await client.queryRecords( + recordType: recordType, + limit: 200, + database: config.database + ) + let visible = allRecords.filter { + createdNames.contains($0.recordName) + } + print(" Visible: \(visible.count)") + if visible.count < 3 { + try await Task.sleep(nanoseconds: 2_000_000_000) + } + + print("\nQuerying with IN filter for [10, 30]...") + let results = try await client.queryRecords( + recordType: recordType, + filters: [.in("index", [.int64(10), .int64(30)])], + limit: 200, + database: config.database + ) + + let matching = results.filter { + createdNames.contains($0.recordName) + } + print("Matching demo records: \(matching.count) (expected 2)") + + if matching.count == 2 { + print("\n IN filter works correctly") + } else { + print("\n Unexpected result count") + } + } + + private func cleanupDemoRecords( + client: CloudKitService, + recordType: String, + createdNames: [String] + ) async throws { + print("\nDeleting demo records...") + for name in createdNames { + let operation = RecordOperation( + operationType: .forceDelete, + recordType: recordType, + recordName: name + ) + _ = try await client.modifyRecords( + [operation], + database: config.database + ) + print(" Deleted \(name)") + } + print("Done.") + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift new file mode 100644 index 00000000..5f50cdeb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift @@ -0,0 +1,157 @@ +// +// FetchChangesCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to fetch record changes with incremental sync +public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = FetchChangesConfig + /// The command name. + public static let commandName = "fetch-changes" + /// The command abstract. + public static let abstract = + "Fetch record changes with incremental sync" + /// The command help text. + public static let helpText = """ + FETCH-CHANGES - Fetch record changes + + USAGE: + mistdemo fetch-changes [options] + + OPTIONS: + --sync-token Sync token from previous fetch + --zone Zone name (default: _defaultZone) + --fetch-all Auto-paginate all changes + --limit Max results per page (1-200) + --database Database to target + --output-format Output format + + EXAMPLES: + mistdemo fetch-changes + mistdemo fetch-changes --fetch-all + mistdemo fetch-changes --sync-token "token" + + NOTES: + Save the returned sync token for next fetch. + """ + + private let config: FetchChangesConfig + + /// Creates a new instance. + public init(config: FetchChangesConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("🔄 Fetch Record Changes") + print(String(repeating: "=", count: 60)) + + let service = try MistKitClientFactory.create( + for: config.base + ) + let zoneID = ZoneID(zoneName: config.zone, ownerName: nil) + + printSyncTokenStatus() + + if config.fetchAll { + try await fetchAllChanges(service: service, zoneID: zoneID) + } else { + try await fetchSinglePage(service: service, zoneID: zoneID) + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Fetch completed!") + print(String(repeating: "=", count: 60)) + } + + private func printSyncTokenStatus() { + if let token = config.syncToken { + print(" Using sync token: \(token.prefix(20))...") + } else { + print(" Performing initial fetch (no sync token)") + } + } + + private func fetchAllChanges( + service: CloudKitService, zoneID: ZoneID + ) async throws { + print("\n📦 Fetching all changes (automatic pagination)...") + let (records, newToken) = try await service.fetchAllRecordChanges( + zoneID: zoneID, + syncToken: config.syncToken, + database: config.base.database + ) + print("\n✅ Fetched \(records.count) record(s)") + displayRecords(records, limit: 5) + if let token = newToken { + print("\n💾 New sync token: \(token.prefix(20))...") + print(" mistdemo fetch-changes --sync-token '\(token)'") + } + } + + private func fetchSinglePage( + service: CloudKitService, zoneID: ZoneID + ) async throws { + print("\n📄 Fetching single page...") + let result = try await service.fetchRecordChanges( + zoneID: zoneID, + syncToken: config.syncToken, + resultsLimit: config.limit ?? 10, + database: config.base.database + ) + print("\n✅ Fetched \(result.records.count) record(s)") + displayRecords(result.records, limit: 5) + + if result.moreComing, let token = result.syncToken { + print("\n⚠️ More changes available!") + print(" mistdemo fetch-changes --sync-token '\(token)'") + } + + if let token = result.syncToken { + print("\n💾 Sync token: \(token.prefix(20))...") + } + } + + private func displayRecords(_ records: [RecordInfo], limit: Int) { + let displayed = records.prefix(limit) + for record in displayed { + print(" 📝 \(record.recordType) - \(record.recordName)") + if !record.fields.isEmpty { + print(" Fields: \(record.fields.keys.joined(separator: ", "))") + } + } + if records.count > limit { + print(" ... and \(records.count - limit) more") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift new file mode 100644 index 00000000..6871ebf5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift @@ -0,0 +1,101 @@ +// +// LookupCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to look up records by name in CloudKit +public struct LookupCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = LookupConfig + /// The command name. + public static let commandName = "lookup" + /// The command abstract. + public static let abstract = "Look up records by name from CloudKit" + /// The command help text. + public static let helpText = """ + LOOKUP - Fetch records by name from CloudKit + + USAGE: + mistdemo lookup --record-names [options] + + REQUIRED: + --api-token CloudKit API token + --web-auth-token Web authentication token + --record-names Comma-separated record names + + OPTIONS: + --fields Restrict returned fields + --output-format Output format + + EXAMPLES: + mistdemo lookup --record-name my-note-123 + mistdemo lookup --record-names note-1,note-2 + mistdemo lookup --record-names note-1 --fields title + + NOTES: + Records not found are omitted from the response. + A warning is printed to stderr listing missing names. + """ + + private let config: LookupConfig + + /// Creates a new instance. + public init(config: LookupConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + do { + let client = try MistKitClientFactory.create(for: config.base) + + let records = try await client.lookupRecords( + recordNames: config.recordNames, + desiredKeys: config.fields, + database: config.base.database + ) + + // Report missing names to stderr so a JSON/CSV/etc. stdout stream stays parseable + let foundNames = Set(records.compactMap { $0.recordName }) + let missing = config.recordNames.filter { !foundNames.contains($0) } + if !missing.isEmpty { + let line = + "Warning: \(missing.count) record(s) not found: \(missing.joined(separator: ", "))\n" + FileHandle.standardError.write(Data(line.utf8)) + } + + try await outputResults(records, format: config.output) + } catch let error as LookupError { + throw error + } catch { + throw LookupError.operationFailed(error.localizedDescription) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift new file mode 100644 index 00000000..3d38519e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift @@ -0,0 +1,103 @@ +// +// LookupZonesCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to look up specific CloudKit zones by name +public struct LookupZonesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = LookupZonesConfig + /// The command name. + public static let commandName = "lookup-zones" + /// The command abstract. + public static let abstract = "Look up specific CloudKit zones by name" + /// The command help text. + public static let helpText = """ + LOOKUP-ZONES - Look up specific CloudKit zones + + USAGE: + mistdemo lookup-zones [options] + + OPTIONS: + --zone-names Zone names (default: _defaultZone) + --database Database to target + --output-format Output format + + EXAMPLES: + mistdemo lookup-zones + mistdemo lookup-zones --database private \\ + --zone-names "Articles,Photos" + + NOTES: + - Auth method follows --database + - Zone names are case-sensitive + """ + + private let config: LookupZonesConfig + + /// Creates a new instance. + public init(config: LookupZonesConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("🔍 Lookup CloudKit Zones") + print(String(repeating: "=", count: 60)) + + let service = try MistKitClientFactory.create(for: config.base) + let zoneIDs = config.zoneNames.map { ZoneID(zoneName: $0, ownerName: nil) } + + print("\n📋 Looking up \(zoneIDs.count) zone(s):") + for name in config.zoneNames { + print(" - \(name)") + } + + let zones = try await service.lookupZones( + zoneIDs: zoneIDs, + database: config.base.database + ) + print("\n✅ Found \(zones.count) zone(s):") + for zone in zones { + print(" - \(zone.zoneName)") + if let owner = zone.ownerRecordName { + print(" Owner: \(owner)") + } + if !zone.capabilities.isEmpty { + print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") + } + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Lookup completed!") + print(String(repeating: "=", count: 60)) + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/MistDemoCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift similarity index 95% rename from Examples/MistDemo/Sources/MistDemo/Commands/MistDemoCommand.swift rename to Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift index 3caa00b7..98e40c8b 100644 --- a/Examples/MistDemo/Sources/MistDemo/Commands/MistDemoCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation public import ConfigKeyKit +import Foundation /// Typealias for MistDemo commands - now uses generic Command protocol -public typealias MistDemoCommand = Command \ No newline at end of file +public typealias MistDemoCommand = Command diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift new file mode 100644 index 00000000..8d11c206 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -0,0 +1,127 @@ +// +// ModifyCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to perform batch create/update/delete operations. +public struct ModifyCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = ModifyConfig + /// The command name. + public static let commandName = "modify" + /// The command abstract. + public static let abstract = + "Run a batch of create/update/delete operations" + /// The command help text. + public static let helpText = """ + MODIFY - Batch create/update/delete operations + + USAGE: + mistdemo modify --operations-file [options] + cat ops.json | mistdemo modify --stdin [options] + + INPUT (choose one): + --operations-file Path to JSON array of ops + --stdin Read JSON from stdin + + OPTIONS: + --atomic Reject batch if any fails + --output-format Output format + + NOTES: + Without --atomic, the server may apply some ops and + reject others. + """ + + private let config: ModifyConfig + + /// Creates a new instance. + public init(config: ModifyConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + do { + let client = try MistKitClientFactory.create( + for: config.base + ) + + let operations = try config.operations.enumerated() + .map { index, input in + try input.toRecordOperation(index: index) + } + + let results = try await client.modifyRecords( + operations, + atomic: config.atomic, + database: config.base.database + ) + + let rows = results.map { record in + ModifyResultRow( + operation: "applied", + recordType: record.recordType, + recordName: record.recordName, + recordChangeTag: record.recordChangeTag + ) + } + + let recordReturningOpsCount = + config.operations + .filter { $0.operation != .delete }.count + let partialFailure = + !config.atomic + && results.count < recordReturningOpsCount + + if partialFailure { + let missing = recordReturningOpsCount - results.count + let line = + "Warning: \(missing) of \(recordReturningOpsCount)" + + " create/update op(s) did not return a record.\n" + FileHandle.standardError.write(Data(line.utf8)) + } + + let envelope = ModifyOutput( + results: rows, + attempted: config.operations.count, + succeeded: results.count, + partialFailure: partialFailure + ) + try await outputResult(envelope, format: config.output) + } catch let error as ModifyError { + throw error + } catch { + throw ModifyError.operationFailed( + error.localizedDescription + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift new file mode 100644 index 00000000..60285350 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift @@ -0,0 +1,55 @@ +// +// ModifyOutput.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// JSON envelope for modify output. +public struct ModifyOutput: Encodable, Sendable { + /// The result rows. + public let results: [ModifyResultRow] + /// The number of operations attempted. + public let attempted: Int + /// The number of operations that succeeded. + public let succeeded: Int + /// Whether the batch was a partial failure. + public let partialFailure: Bool + + /// Creates a new instance. + public init( + results: [ModifyResultRow], + attempted: Int, + succeeded: Int, + partialFailure: Bool + ) { + self.results = results + self.attempted = attempted + self.succeeded = succeeded + self.partialFailure = partialFailure + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift new file mode 100644 index 00000000..d4ea90b7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift @@ -0,0 +1,62 @@ +// +// ModifyResultRow.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// One row in the modify command's output. +public struct ModifyResultRow: Encodable, Sendable { + private enum CodingKeys: String, CodingKey { + case operation = "op" + case recordType + case recordName + case recordChangeTag + } + + /// The operation type applied. + public let operation: String + /// The record type. + public let recordType: String + /// The record name. + public let recordName: String? + /// The record change tag. + public let recordChangeTag: String? + + /// Creates a new instance. + public init( + operation: String, + recordType: String, + recordName: String?, + recordChangeTag: String? + ) { + self.operation = operation + self.recordType = recordType + self.recordName = recordName + self.recordChangeTag = recordChangeTag + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift new file mode 100644 index 00000000..fc38ab06 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift @@ -0,0 +1,153 @@ +// +// QueryCommand+FilterParsing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +extension QueryCommand { + /// Parse a single filter expression "field:operator:value" into a QueryFilter + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func parseFilter(_ filterString: String) throws -> QueryFilter { + let components = filterString.split( + separator: ":", maxSplits: 2, omittingEmptySubsequences: false + ) + + guard components.count == 3 else { + throw QueryError.invalidFilter(filterString, expected: "field:operator:value") + } + + let field = String(components[0]).trimmingCharacters(in: .whitespaces) + let operatorString = String(components[1]).trimmingCharacters(in: .whitespaces) + let value = String(components[2]) + + guard !field.isEmpty else { + throw QueryError.emptyFieldName(filterString) + } + + return try buildFilter(field: field, operatorString: operatorString, value: value) + } + + /// Build a QueryFilter from parsed components. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func buildFilter( + field: String, + operatorString: String, + value: String + ) throws -> QueryFilter { + if let comparison = buildComparisonFilter( + field: field, operatorString: operatorString, value: value + ) { + return comparison + } + return try buildSpecialFilter( + field: field, operatorString: operatorString, value: value + ) + } + + /// Build comparison-based filters (equals, not equals, greater/less than). + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + // swiftlint:disable:next cyclomatic_complexity + internal static func buildComparisonFilter( + field: String, + operatorString: String, + value: String + ) -> QueryFilter? { + switch operatorString.lowercased() { + case "eq", "equals", "==", "=": + return .equals(field, inferFieldValue(value)) + case "ne", "not_equals", "!=": + return .notEquals(field, inferFieldValue(value)) + case "gt", ">": + return .greaterThan(field, inferFieldValue(value)) + case "gte", ">=": + return .greaterThanOrEquals( + field, inferFieldValue(value) + ) + case "lt", "<": + return .lessThan(field, inferFieldValue(value)) + case "lte", "<=": + return .lessThanOrEquals( + field, inferFieldValue(value) + ) + default: + return nil + } + } + + /// Build string and list-based filters. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func buildSpecialFilter( + field: String, + operatorString: String, + value: String + ) throws -> QueryFilter { + switch operatorString.lowercased() { + case "contains", "like": + return .containsAllTokens(field, value) + case "begins_with", "starts_with": + return .beginsWith(field, value) + case "in": + let values = value.split(separator: ",").map { + inferFieldValue(String($0)) + } + return .in(field, values) + case "not_in": + let values = value.split(separator: ",").map { + inferFieldValue(String($0)) + } + return .notIn(field, values) + default: + throw QueryError.unsupportedOperator(operatorString) + } + } + + /// Infer a FieldValue from a string. + internal static func inferFieldValue( + _ string: String + ) -> FieldValue { + if let intValue = Int64(string) { + return .int64(Int(intValue)) + } + if let doubleValue = Double(string) { + return .double(doubleValue) + } + return .string(string) + } + + /// Check if a field should be included based on field filter + internal static func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { + guard let fields = fields, !fields.isEmpty else { + return true // Include all fields if no filter specified + } + + return fields.contains { requestedField in + fieldName.lowercased() == requestedField.lowercased() + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift new file mode 100644 index 00000000..4d0a9ea2 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -0,0 +1,105 @@ +// +// QueryCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to query Note records from CloudKit with filtering and sorting +public struct QueryCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = QueryConfig + /// The command name. + public static let commandName = "query" + /// The command abstract. + public static let abstract = + "Query records from CloudKit with filtering and sorting" + /// The command help text. + public static let helpText = """ + QUERY - Query records from CloudKit + + USAGE: + mistdemo query [options] + + OPTIONS: + --record-type Record type (default: Note) + --filter Filter: field:operator:value + --sort Sort (asc/desc) + --limit Max records (1-200) + --fields Comma-separated fields + --output-format Output format + """ + + private let config: QueryConfig + + /// Creates a new instance. + public init(config: QueryConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + do { + // Create CloudKit client + let client = try MistKitClientFactory.create(for: config.base) + + // Build filters + // NOTE: Zone, offset, and continuation marker support require + // enhancements to CloudKitService.queryRecords method (GitHub issues #145, #146) + let recordInfos: [RecordInfo] + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + let filters: [QueryFilter]? = + config.filters.isEmpty + ? nil + : try config.filters.map { try Self.parseFilter($0) } + recordInfos = try await client.queryRecords( + recordType: config.recordType, + filters: filters, + sortBy: nil, + limit: config.limit, + database: config.base.database + ) + } else { + recordInfos = try await client.queryRecords( + recordType: config.recordType, + filters: nil, + sortBy: nil, + limit: config.limit, + database: config.base.database + ) + } + + // Format and output results + try await outputResults(recordInfos, format: config.output) + } catch { + throw QueryError.operationFailed(error.localizedDescription) + } + } +} + +// QueryError is now defined in Errors/QueryError.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift new file mode 100644 index 00000000..b3b701c9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -0,0 +1,103 @@ +// +// TestPrivateCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to run comprehensive integration tests against the private database, +/// covering all CloudKit API methods including user-identity endpoints. +public struct TestPrivateCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = TestPrivateConfig + /// The command name. + public static let commandName = "test-private" + /// The command abstract. + public static let abstract = + "Run integration tests for private database" + /// The command help text. + public static let helpText = """ + TEST-PRIVATE - Integration tests (private database) + + Tests all CloudKit API methods including user-identity + endpoints requiring private database access. + + USAGE: + mistdemo test-private [options] + + OPTIONS: + --record-count Test records (default: 10) + --asset-size Asset size in KB (default: 100) + --skip-cleanup Skip cleanup after test + --verbose Run in verbose mode + --lookup-email + Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL). + Must belong to an iCloud account discoverable to the caller, + otherwise the phase skips. + + EXAMPLES: + mistdemo test-private --verbose + mistdemo test-private --skip-cleanup --verbose + mistdemo test-private --lookup-email me@example.com + + NOTES: + - Requires CLOUDKIT_API_TOKEN and + CLOUDKIT_WEB_AUTH_TOKEN + - Use 'test-public' for public-database tests + """ + + private let config: TestPrivateConfig + + /// Creates a new instance. + public init(config: TestPrivateConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + let service = try MistKitClientFactory.create(for: config.base) + // Private-database flows always carry web-auth credentials, so the same + // service can also serve user-identity routes when this command needs + // them. Per-call resolution picks the right token manager. + let supportsUserContextPhases = config.base.hasUserContextCredentials + + let runner = IntegrationTestRunner( + service: service, + supportsUserContextPhases: supportsUserContextPhases, + containerIdentifier: config.base.containerIdentifier, + database: .private, + recordCount: config.recordCount, + assetSizeKB: config.assetSizeKB, + skipCleanup: config.skipCleanup, + verbose: config.verbose, + lookupEmail: config.lookupEmail + ) + + try await runner.runPrivateWorkflow() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift new file mode 100644 index 00000000..408114b8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift @@ -0,0 +1,103 @@ +// +// TestPublicCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to run comprehensive integration tests for all CloudKit operations +public struct TestPublicCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = TestPublicConfig + /// The command name. + public static let commandName = "test-public" + /// The command abstract. + public static let abstract = + "Run integration tests for all CloudKit operations" + /// The command help text. + public static let helpText = """ + TEST-PUBLIC - Integration tests (public database) + + Tests all non-user-scoped CloudKit API methods against + the public database. Use 'test-private' for user APIs. + + USAGE: + mistdemo test-public [options] + + OPTIONS: + --database Database (default: public) + --record-count Test records (default: 10) + --asset-size Asset size in KB (default: 100) + --skip-cleanup Skip cleanup after test + --verbose Run in verbose mode + --lookup-email + Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL). + Must belong to an iCloud account discoverable to the caller, + otherwise the phase skips. + + EXAMPLES: + mistdemo test-public --verbose + mistdemo test-public --skip-cleanup --verbose + mistdemo test-public --lookup-email me@example.com + + NOTES: + - Requires CLOUDKIT_KEY_ID and CLOUDKIT_PRIVATE_KEY + - Use 'test-private' for user-identity coverage + """ + + private let config: TestPublicConfig + + /// Creates a new instance. + public init(config: TestPublicConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + let service = try MistKitClientFactory.create(for: config.base) + // A single service handles every phase: server-to-server signing on + // `.public` for record ops, plus web-auth for user-identity routes when + // the API/web-auth env vars are populated. The resolver picks the right + // token manager per call. + let supportsUserContextPhases = config.base.hasUserContextCredentials + + let runner = IntegrationTestRunner( + service: service, + supportsUserContextPhases: supportsUserContextPhases, + containerIdentifier: config.base.containerIdentifier, + database: config.base.database, + recordCount: config.recordCount, + assetSizeKB: config.assetSizeKB, + skipCleanup: config.skipCleanup, + verbose: config.verbose, + lookupEmail: config.lookupEmail + ) + + try await runner.runBasicWorkflow() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift new file mode 100644 index 00000000..eabce926 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift @@ -0,0 +1,129 @@ +// +// UpdateCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to update an existing record in CloudKit +public struct UpdateCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = UpdateConfig + /// The command name. + public static let commandName = "update" + /// The command abstract. + public static let abstract = "Update an existing record in CloudKit" + /// The command help text. + public static let helpText = """ + UPDATE - Update an existing record in CloudKit + + USAGE: + mistdemo update --record-name [options] + + REQUIRED: + --record-name Record name to update + + OPTIONS: + --record-type Record type (default: Note) + --record-change-tag Optimistic locking tag + --force Overwrite ignoring conflicts + --output-format Output format + + FIELD DEFINITION: + --field Inline field definition + --json-file Load fields from JSON + --stdin Read fields from stdin + + EXAMPLES: + mistdemo update --record-name my-note-123 \\ + --field "title:string:Updated Title" + mistdemo update --record-name my-note-123 \\ + --json-file updates.json + """ + + private let config: UpdateConfig + + /// Creates a new instance. + public init(config: UpdateConfig) { + self.config = config + } + + private static func mapConflict( + _ error: CloudKitError + ) -> UpdateError? { + switch error { + case .httpError(let statusCode) where statusCode == 409: + return .conflict(reason: nil) + case .httpErrorWithDetails( + let statusCode, _, let reason + ) where statusCode == 409: + return .conflict(reason: reason) + case .httpErrorWithRawResponse( + let statusCode, _ + ) where statusCode == 409: + return .conflict(reason: nil) + default: + return nil + } + } + + /// Executes the command. + public func execute() async throws { + do { + let client = try MistKitClientFactory.create( + for: config.base + ) + let cloudKitFields = try config.fields.toCloudKitFields() + let effectiveChangeTag = + config.force ? nil : config.recordChangeTag + + let recordInfo = try await client.updateRecord( + recordType: config.recordType, + recordName: config.recordName, + fields: cloudKitFields, + recordChangeTag: effectiveChangeTag, + database: config.base.database + ) + + try await outputResult(recordInfo, format: config.output) + } catch let error as UpdateError { + throw error + } catch let error as CloudKitError { + if let mapped = Self.mapConflict(error) { + throw mapped + } + throw UpdateError.operationFailed( + error.localizedDescription + ) + } catch { + throw UpdateError.operationFailed( + error.localizedDescription + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift new file mode 100644 index 00000000..84c0b683 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift @@ -0,0 +1,218 @@ +// +// UploadAssetCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Command to upload binary assets to CloudKit +public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = UploadAssetConfig + /// The command name. + public static let commandName = "upload-asset" + /// The command abstract. + public static let abstract = "Upload binary assets to CloudKit" + /// The command help text. + public static let helpText = """ + UPLOAD-ASSET - Upload binary assets to CloudKit + + USAGE: + mistdemo upload-asset --file [options] + + REQUIRED: + --file File to upload + + OPTIONS: + --record-type Record type (default: Note) + --field-name Asset field (default: image) + --record-name Record name (auto-generated) + --output-format Output format + + EXAMPLES: + mistdemo upload-asset --file photo.jpg + mistdemo upload-asset --file photo.jpg \\ + --record-type Photo --field-name thumbnail + + NOTES: + Maximum file size: 15 MB. + Upload URLs valid for 15 minutes. + """ + + private let config: UploadAssetConfig + + /// Creates a new instance. + public init(config: UploadAssetConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("📤 Upload Asset to CloudKit") + print(String(repeating: "=", count: 60)) + + let fileURL = URL(fileURLWithPath: config.file) + guard FileManager.default.fileExists(atPath: config.file) + else { + throw UploadAssetError.fileNotFound(config.file) + } + + do { + let data = try readAndValidateFile(fileURL: fileURL) + let service = try MistKitClientFactory.create( + for: config.base + ) + let result = try await uploadAsset( + data: data, service: service + ) + try await attachAssetToRecord( + result: result, service: service + ) + } catch let error as UploadAssetError { + throw error + } catch let error as CloudKitError { + throw UploadAssetError.operationFailed( + error.localizedDescription + ) + } catch { + throw UploadAssetError.operationFailed( + error.localizedDescription + ) + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Upload completed!") + print(String(repeating: "=", count: 60)) + } + + private func readAndValidateFile( + fileURL: URL + ) throws -> Data { + let data = try Data(contentsOf: fileURL) + let maxSize: Int64 = 15 * 1_024 * 1_024 + if data.count > maxSize { + throw UploadAssetError.fileTooLarge( + Int64(data.count), maximum: maxSize + ) + } + let sizeInMB = Double(data.count) / 1_024 / 1_024 + let sizeStr = String(format: "%.2f", sizeInMB) + print("\n📁 File: \(fileURL.lastPathComponent) (\(sizeStr) MB)") + print("📝 Record Type: \(config.recordType)") + return data + } + + private func uploadAsset( + data: Data, + service: CloudKitService + ) async throws -> AssetUploadReceipt { + print("\n⬆️ Uploading...") + let result = try await service.uploadAssets( + data: data, + recordType: config.recordType, + fieldName: config.fieldName, + recordName: config.recordName, + database: config.base.database + ) + print("\n✅ Asset uploaded!") + print(" Record Name: \(result.recordName)") + return result + } + + private func attachAssetToRecord( + result: AssetUploadReceipt, + service: CloudKitService + ) async throws { + print("\n📝 Creating record with asset...") + do { + let recordInfo = try await createOrUpdateRecord( + result: result, service: service + ) + print("✅ Record Name: \(recordInfo.recordName)") + try await outputResult(recordInfo, format: config.output) + } catch { + print("\n⚠️ Record operation failed:") + print(" \(error.localizedDescription)") + } + } + + /// Create or update a record with the uploaded asset. + private func createOrUpdateRecord( + result: AssetUploadReceipt, + service: CloudKitService + ) async throws -> RecordInfo { + var fields: [String: FieldValue] = [ + config.fieldName: .asset(result.asset) + ] + + if let recordName = config.recordName { + return try await updateExistingRecord( + recordName: recordName, + fields: fields, + service: service + ) + } else { + if config.recordType == "Note" { + fields["title"] = .string( + "Uploaded Image - \(Date().formatted())" + ) + } + let newRecordName = UUID().uuidString.lowercased() + return try await service.createRecord( + recordType: config.recordType, + recordName: newRecordName, + fields: fields, + database: config.base.database + ) + } + } + + private func updateExistingRecord( + recordName: String, + fields: [String: FieldValue], + service: CloudKitService + ) async throws -> RecordInfo { + let existingRecords = try await service.lookupRecords( + recordNames: [recordName], + database: config.base.database + ) + guard let existingRecord = existingRecords.first else { + throw UploadAssetError.operationFailed( + "Record '\(recordName)' not found" + ) + } + return try await service.updateRecord( + recordType: config.recordType, + recordName: recordName, + fields: fields, + recordChangeTag: existingRecord.recordChangeTag, + database: config.base.database + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift new file mode 100644 index 00000000..abb63ace --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -0,0 +1,183 @@ +// +// WebCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + /// Long-running interactive web demo: serves a single HTML page that + /// performs the CloudKit auth round trip and then exposes a CRUD UI + /// driven by MistKit on the server. + /// + /// Unlike `AuthTokenCommand`, this command does not exit after the + /// browser-side auth completes — the server keeps running so the user + /// can exercise the CRUD endpoints until they Ctrl+C. + public struct WebCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = WebConfig + + /// The command name. + public static let commandName = "web" + /// The command abstract. + public static let abstract = + "Run the interactive MistKit web demo (CRUD + auth)" + /// The command help text. + public static let helpText = """ + WEB - Interactive MistKit web demo + + USAGE: + mistdemo web [options] + + OPTIONS: + --api-token CloudKit API token + --environment development (default) | production + --port Server port (default: 8080) + --host Server host (default: 127.0.0.1) + --browser Open browser on startup (overrides default) + --no-browser Don't open browser on startup (default for web) + + OPTIONAL — public database (server-to-server): + --key-id CloudKit server-to-server key ID + --private-key Server-to-server private key (inline PEM) + --private-key-path

Path to server-to-server private key file + + The page authenticates against CloudKit via the browser, then + exposes a CRUD UI that calls MistKit on the server. When key + material is provided, the UI also exposes a public-database mode + that signs requests with the key pair instead of the browser- + captured web auth token. Ctrl+C to exit. + """ + + internal let config: WebConfig + + /// Creates a new instance. + public init(config: WebConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("📍 Server URL: http://\(config.host):\(config.port)") + if config.publicDatabaseAvailable { + print("🌐 Public database (server-to-server) mode available.") + } + print("Press Ctrl+C to stop.") + + let tokenStore = WebAuthTokenStore() + let server = WebServer( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + publicDatabaseAvailable: config.publicDatabaseAvailable, + tokenStore: tokenStore, + backendFactory: .live( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + serverToServer: try makeServerToServerCredentials() + ), + terminatesAfterAuth: false + ) + let router = try server.makeRouter() + + let app = Application( + router: router, + configuration: .init( + address: .hostname(config.host, port: config.port) + ) + ) + + do { + try await withSignalHandling { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await app.runService() + } + group.addTask { + await openBrowserIfNeeded() + } + try await group.waitForAll() + } + } + } catch AsyncTimeoutError.cancelled { + // Ctrl+C / SIGTERM is the intended exit path for the long-running + // web server — `withSignalHandling` throws cancelled to unwind the + // task group. Treat it as a clean shutdown. + print("Server stopped.") + } + } + + /// Build server-to-server credentials when the user supplied key + /// material. Returns `nil` (i.e. private-only mode) when nothing is + /// provided; throws only if an incomplete combination is supplied so + /// silent misconfigurations don't masquerade as "public unavailable". + private func makeServerToServerCredentials() throws + -> ServerToServerCredentials? + { + let hasKeyID = (config.keyID?.isEmpty == false) + let hasInlineKey = (config.privateKey?.isEmpty == false) + let hasKeyFile = (config.privateKeyFile?.isEmpty == false) + + guard hasKeyID || hasInlineKey || hasKeyFile else { + return nil + } + guard let keyID = config.keyID, !keyID.isEmpty else { + throw ConfigurationError.missingRequired( + "key.id", + suggestion: "Provide via --key-id or CLOUDKIT_KEY_ID environment variable" + ) + } + + let material: PrivateKeyMaterial + if let inline = config.privateKey, !inline.isEmpty { + material = .raw(inline) + } else if let path = config.privateKeyFile, !path.isEmpty { + material = .file(path: path) + } else { + throw ConfigurationError.missingRequired( + "private.key", + suggestion: "Provide via --private-key or --private-key-path" + ) + } + + return ServerToServerCredentials(keyID: keyID, privateKey: material) + } + + private func openBrowserIfNeeded() async { + guard config.openBrowser else { + return + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + BrowserOpener.openBrowser( + url: "http://\(config.host):\(config.port)" + ) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift new file mode 100644 index 00000000..856b03b7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -0,0 +1,127 @@ +// +// AuthTokenConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for auth-token command. +public struct AuthTokenConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = Never + + /// The CloudKit API token. + public let apiToken: String + /// The CloudKit container identifier. + public let containerIdentifier: String + /// The CloudKit environment (development / production). + public let environment: MistKit.Environment + /// The server port for authentication. + public let port: Int + /// The server host for authentication. + public let host: String + /// Whether to open the browser to the demo URL on startup. + /// Defaults to `true` for `auth-token` — the captured token is the + /// command's whole reason for existing, so a hands-off flow is the + /// expected UX. + public let openBrowser: Bool + + /// Creates a new instance. + public init( + apiToken: String, + // Demo default — override via --container-identifier or config key "container.identifier" + containerIdentifier: String = MistDemoConstants.Defaults.containerIdentifier, + environment: MistKit.Environment = .development, + port: Int = 8_080, + host: String = "127.0.0.1", + openBrowser: Bool = true + ) { + self.apiToken = apiToken + self.containerIdentifier = containerIdentifier + self.environment = environment + self.port = port + self.host = host + self.openBrowser = openBrowser + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: Never? = nil + ) async throws { + let configReader = configuration + + // Parse command-specific options + let apiToken = + configReader.string(forKey: "api.token", isSecret: true) ?? "" + guard !apiToken.isEmpty else { + throw ConfigurationError.missingRequired( + "api.token", + suggestion: + "Provide via --api-token or CLOUDKIT_API_TOKEN environment variable" + ) + } + + // Demo default — override via --container-identifier + // or config key "container.identifier" + let containerIdentifier = + configReader.string( + forKey: "container.identifier", + default: MistDemoConstants.Defaults.containerIdentifier + ) ?? MistDemoConstants.Defaults.containerIdentifier + + let envString = + configReader.string(forKey: "environment", default: "development") + ?? "development" + guard let environment = MistKit.Environment(caseInsensitive: envString) else { + throw ConfigurationError.invalidEnvironment(envString) + } + + let port = + configReader.int(forKey: "port", default: 8_080) ?? 8_080 + let host = + configReader.string(forKey: "host", default: "127.0.0.1") + ?? "127.0.0.1" + let openBrowser = BrowserFlagResolver.resolve( + configReader: configReader, + default: true + ) + + self.init( + apiToken: apiToken, + containerIdentifier: containerIdentifier, + environment: environment, + port: port, + host: host, + openBrowser: openBrowser + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift new file mode 100644 index 00000000..5c39f1d2 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift @@ -0,0 +1,53 @@ +// +// BrowserFlagResolver.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Resolves the "should we open the browser on startup?" decision from +/// the two mutually-exclusive CLI flags into a single boolean. +/// +/// - `--no-browser` sets `no.browser=true` → resolves to `false` (wins). +/// - `--browser` sets `browser=true` → resolves to `true`. +/// - Neither set → falls back to the per-command default. +internal enum BrowserFlagResolver { + internal static func resolve( + configReader: MistDemoConfiguration, + default defaultValue: Bool + ) -> Bool { + let noBrowser = configReader.bool(forKey: "no.browser", default: false) + if noBrowser { + return false + } + let browser = configReader.bool(forKey: "browser", default: false) + if browser { + return true + } + return defaultValue + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift new file mode 100644 index 00000000..b9eb0e66 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift @@ -0,0 +1,59 @@ +// +// ConfigurationError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Configuration errors. +internal enum ConfigurationError: LocalizedError { + case invalidEnvironment(String) + case invalidDatabase(String) + case missingRequired(String, suggestion: String) + case unsupportedPlatform(String) + case badCredentialsOnPublicDB + + // MARK: Internal + + internal var errorDescription: String? { + switch self { + case .invalidEnvironment(let env): + "Invalid environment '\(env)'. Must be 'development' or 'production'" + case .invalidDatabase(let database): + "Invalid database '\(database)'. " + + "Must be 'public', 'private', or 'shared'" + case .missingRequired(let field, let suggestion): + "Missing required configuration: \(field). \(suggestion)" + case .unsupportedPlatform(let message): + "Unsupported platform: \(message)" + case .badCredentialsOnPublicDB: + "The bad-credentials error demo is only supported on the " + + "private and shared databases (it uses web auth). " + + "Re-run with `--database private`." + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift new file mode 100644 index 00000000..c7bb3880 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift @@ -0,0 +1,198 @@ +// +// CreateConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for create command. +public struct CreateConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The CloudKit zone name. + public let zone: String + /// The CloudKit record type. + public let recordType: String + /// The optional record name. + public let recordName: String? + /// The fields to set on the record. + public let fields: [Field] + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zone: String = "_defaultZone", + recordType: String = "Note", + recordName: String? = nil, + fields: [Field] = [], + output: OutputFormat = .json + ) { + self.base = base + self.zone = zone + self.recordType = recordType + self.recordName = recordName + self.fields = fields + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base = base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + // Parse create-specific options + let zone = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.zone, + default: MistDemoConstants.Defaults.zone + ) ?? MistDemoConstants.Defaults.zone + let recordType = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordType, + default: MistDemoConstants.Defaults.recordType + ) ?? MistDemoConstants.Defaults.recordType + let recordName = configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordName + ) + + // Parse fields from various sources + let fields = try Self.parseFieldsFromSources(configReader) + + // Parse output format + let outputString = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + zone: zone, + recordType: recordType, + recordName: recordName, + fields: fields, + output: output + ) + } + + private static func parseFieldsFromSources( + _ configReader: MistDemoConfiguration + ) throws -> [Field] { + var fields: [Field] = [] + + // 1. Parse inline field definitions + if let fieldString = configReader.string(forKey: "field") { + let fieldDefinitions = fieldString.split(separator: ",").map { + String($0).trimmingCharacters(in: .whitespaces) + } + let inlineFields = try Field.parseFields(fieldDefinitions) + fields.append(contentsOf: inlineFields) + } + + // 2. Parse from JSON file + if let jsonFile = configReader.string( + forKey: MistDemoConstants.ConfigKeys.jsonFile + ) { + let jsonFields = try parseFieldsFromJSONFile(jsonFile) + fields.append(contentsOf: jsonFields) + } + + // 3. Parse from stdin (check if data is available) + if configReader.bool( + forKey: MistDemoConstants.ConfigKeys.stdin, + default: false + ) { + let stdinFields = try parseFieldsFromStdin() + fields.append(contentsOf: stdinFields) + } + + guard !fields.isEmpty else { + throw CreateError.noFieldsProvided + } + + return fields + } + + /// Parse fields from JSON file. + private static func parseFieldsFromJSONFile( + _ filePath: String + ) throws -> [Field] { + do { + let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) + let fieldsInput = try JSONDecoder().decode( + FieldsInput.self, + from: data + ) + return try fieldsInput.toFields() + } catch { + throw CreateError.jsonFileError( + filePath, + error.localizedDescription + ) + } + } + + /// Parse fields from stdin. + private static func parseFieldsFromStdin() throws -> [Field] { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + + guard !stdinData.isEmpty else { + throw CreateError.emptyStdin + } + + do { + let fieldsInput = try JSONDecoder().decode( + FieldsInput.self, + from: stdinData + ) + return try fieldsInput.toFields() + } catch { + throw CreateError.stdinError(error.localizedDescription) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift new file mode 100644 index 00000000..17f23d98 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift @@ -0,0 +1,93 @@ +// +// CurrentUserConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for current-user command. +public struct CurrentUserConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The optional field names to include in the response. + public let fields: [String]? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + fields: [String]? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.fields = fields + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base = base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + // Parse fields filter + let fieldsString = configReader.string(forKey: "fields") + let fields = fieldsString?.split(separator: ",").map { + String($0).trimmingCharacters(in: .whitespaces) + } + + // Parse output format + let outputString = + configReader.string(forKey: "output.format", default: "json") + ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + fields: fields, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift new file mode 100644 index 00000000..80adf6f4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift @@ -0,0 +1,132 @@ +// +// DeleteConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation + +/// Configuration for delete command. +public struct DeleteConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The CloudKit zone name. + public let zone: String + /// The CloudKit record type. + public let recordType: String + /// The record name to delete. + public let recordName: String + /// The optional record change tag for conflict detection. + public let recordChangeTag: String? + /// Whether to force deletion without change tag. + public let force: Bool + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zone: String = "_defaultZone", + recordType: String = "Note", + recordName: String, + recordChangeTag: String? = nil, + force: Bool = false, + output: OutputFormat = .json + ) { + self.base = base + self.zone = zone + self.recordType = recordType + self.recordName = recordName + self.recordChangeTag = recordChangeTag + self.force = force + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let zone = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.zone, + default: MistDemoConstants.Defaults.zone + ) ?? MistDemoConstants.Defaults.zone + let recordType = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordType, + default: MistDemoConstants.Defaults.recordType + ) ?? MistDemoConstants.Defaults.recordType + + guard + let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) + else { + throw DeleteError.recordNameRequired + } + + let recordChangeTag = configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordChangeTag + ) + let force = configReader.bool( + forKey: MistDemoConstants.ConfigKeys.force, + default: false + ) + + let outputString = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + zone: zone, + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag, + force: force, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift new file mode 100644 index 00000000..f2187d4c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift @@ -0,0 +1,75 @@ +// +// DemoErrorsConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation + +/// Configuration for demo-errors command. +public struct DemoErrorsConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The error scenario to demonstrate. + public let scenario: ErrorScenario + + /// Creates a new instance. + public init(base: MistDemoConfig, scenario: ErrorScenario = .all) { + self.base = base + self.scenario = scenario + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let scenarioString = + configuration.string(forKey: "scenario", default: "all") + ?? "all" + guard let scenario = ErrorScenario(rawValue: scenarioString) else { + throw DemoErrorsError.invalidScenario(scenarioString) + } + + self.init(base: baseConfig, scenario: scenario) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift new file mode 100644 index 00000000..10568921 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift @@ -0,0 +1,44 @@ +// +// DemoErrorsError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Errors specific to the demo-errors command's configuration parsing. +internal enum DemoErrorsError: LocalizedError { + case invalidScenario(String) + + internal var errorDescription: String? { + switch self { + case .invalidScenario(let value): + return + "Invalid --scenario '\(value)'. Must be one of: " + + ErrorScenario.allCases.map(\.rawValue).joined(separator: ", ") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ErrorScenario.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ErrorScenario.swift new file mode 100644 index 00000000..ec724965 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ErrorScenario.swift @@ -0,0 +1,36 @@ +// +// ErrorScenario.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Which error scenario(s) the `demo-errors` command should run. +public enum ErrorScenario: String, Sendable, CaseIterable { + case all + case unauthorized = "401" + case notFound = "404" + case conflict = "409" +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift new file mode 100644 index 00000000..fce62fa4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift @@ -0,0 +1,105 @@ +// +// FetchChangesConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit + +/// Configuration for fetch-changes command. +public struct FetchChangesConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The optional sync token for incremental changes. + public let syncToken: String? + /// The CloudKit zone name. + public let zone: String + /// Whether to fetch all changes via auto-pagination. + public let fetchAll: Bool + /// The optional limit on number of changes to fetch. + public let limit: Int? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + syncToken: String? = nil, + zone: String = "_defaultZone", + fetchAll: Bool = false, + limit: Int? = nil, + output: OutputFormat = .table + ) { + self.base = base + self.syncToken = syncToken + self.zone = zone + self.fetchAll = fetchAll + self.limit = limit + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let syncToken = configuration.string(forKey: "sync.token") + let zone = + configuration.string(forKey: "zone", default: "_defaultZone") + ?? "_defaultZone" + let fetchAll = + configuration.bool(forKey: "fetch.all", default: false) + let limit = configuration.int(forKey: "limit") + let outputString = + configuration.string(forKey: "output.format", default: "table") + ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + syncToken: syncToken, + zone: zone, + fetchAll: fetchAll, + limit: limit, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift new file mode 100644 index 00000000..b94b8ec9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift @@ -0,0 +1,117 @@ +// +// Field.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Field definition for create operations. +public struct Field: Sendable { + /// The field name. + public let name: String + /// The field type. + public let type: FieldType + /// The field value as a string. + public let value: String + + /// Creates a new instance. + public init(name: String, type: FieldType, value: String) { + self.name = name + self.type = type + self.value = value + } + + /// Parse a field from string format "name:type:value" + /// - Parameter input: String in format "name:type:value" + /// - Throws: FieldParsingError if the format is invalid + public init(parsing input: String) throws { + let components = input.split( + separator: ":", + maxSplits: 2, + omittingEmptySubsequences: false + ) + + guard components.count == 3 else { + throw FieldParsingError.invalidFormat( + input, + expected: "name:type:value" + ) + } + + let name = String(components[0]).trimmingCharacters( + in: .whitespaces + ) + let typeString = String(components[1]).trimmingCharacters( + in: .whitespaces + ) + // Don't trim value as it may contain meaningful whitespace + let value = String(components[2]) + + guard !name.isEmpty else { + throw FieldParsingError.emptyFieldName(input) + } + + guard let type = FieldType(rawValue: typeString.lowercased()) else { + throw FieldParsingError.unknownFieldType( + typeString, + available: FieldType.allCases.map(\.rawValue) + ) + } + + self.init(name: name, type: type, value: value) + } + + /// Parse multiple fields from an array of strings. + /// - Parameter inputs: Array of strings in format "name:type:value" + /// - Returns: Array of parsed Field instances + /// - Throws: FieldParsingError if any field has an invalid format + public static func parseMultiple(_ inputs: [String]) throws -> [Field] { + try inputs.map { try Field(parsing: $0) } + } + + /// Legacy parse method - delegates to init(parsing:) + /// - Deprecated: Use `init(parsing:)` instead + @available( + *, deprecated, renamed: "init(parsing:)", + message: "Use Field(parsing:) instead of Field.parse()" + ) + public static func parse(_ input: String) throws -> Field { + try Field(parsing: input) + } + + /// Legacy parseFields method - delegates to parseMultiple. + /// - Deprecated: Use `parseMultiple(_:)` instead + @available( + *, deprecated, renamed: "parseMultiple", + message: "Use Field.parseMultiple() instead" + ) + public static func parseFields( + _ inputs: [String] + ) throws -> [Field] { + try parseMultiple(inputs) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldParsingError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldParsingError.swift new file mode 100644 index 00000000..3ef315a0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldParsingError.swift @@ -0,0 +1,59 @@ +// +// FieldParsingError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during field parsing. +public enum FieldParsingError: Error, LocalizedError { + case invalidFormat(String, expected: String) + case emptyFieldName(String) + case unknownFieldType(String, available: [String]) + case invalidValueForType(String, type: FieldType) + case unsupportedFieldType(FieldType) + + /// The localized description of the error. + public var errorDescription: String? { + switch self { + case .invalidFormat(let input, let expected): + return + "Invalid field format '\(input)'. Expected format: \(expected)" + case .emptyFieldName(let input): + return "Empty field name in '\(input)'" + case .unknownFieldType(let type, let available): + return + "Unknown field type '\(type)'. " + + "Available types: \(available.joined(separator: ", "))" + case .invalidValueForType(let value, let type): + return + "Invalid value '\(value)' for field type '\(type.rawValue)'" + case .unsupportedFieldType(let type): + return "Field type '\(type.rawValue)' is not yet supported" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift new file mode 100644 index 00000000..2eafc824 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift @@ -0,0 +1,97 @@ +// +// FieldType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Supported field types for CloudKit records. +public enum FieldType: String, CaseIterable, Sendable { + case string + case int64 + case double + case timestamp + case asset + case location + case reference + case bytes + + /// Convert field value to appropriate CloudKit field value. + public func convertValue(_ stringValue: String) throws -> Any { + switch self { + case .string: + return stringValue + case .int64: + return try convertInt64(stringValue) + case .double: + return try convertDouble(stringValue) + case .timestamp: + return try convertTimestamp(stringValue) + case .asset: + // stringValue should be the URL from the upload token + return stringValue + case .location, .reference, .bytes: + throw FieldParsingError.unsupportedFieldType(self) + } + } + + private func convertInt64(_ stringValue: String) throws -> Int64 { + guard let intValue = Int64(stringValue) else { + throw FieldParsingError.invalidValueForType( + stringValue, + type: self + ) + } + return intValue + } + + private func convertDouble(_ stringValue: String) throws -> Double { + guard let doubleValue = Double(stringValue) else { + throw FieldParsingError.invalidValueForType( + stringValue, + type: self + ) + } + return doubleValue + } + + private func convertTimestamp( + _ stringValue: String + ) throws -> Date { + // Try parsing as ISO 8601 first, then as timestamp + if let date = ISO8601DateFormatter().date(from: stringValue) { + return date + } else if let timestamp = Double(stringValue) { + return Date(timeIntervalSince1970: timestamp) + } else { + throw FieldParsingError.invalidValueForType( + stringValue, + type: self + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift new file mode 100644 index 00000000..9c51fbbc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift @@ -0,0 +1,121 @@ +// +// LookupConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation + +/// Configuration for lookup command. +public struct LookupConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The record names to look up. + public let recordNames: [String] + /// The optional field names to include in the response. + public let fields: [String]? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + recordNames: [String], + fields: [String]? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.recordNames = recordNames + self.fields = fields + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + // --record-names accepts a comma-separated list. + // --record-name (singular) also works for a single name. + let recordNames: [String] + if let raw = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordNames) { + recordNames = + raw + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } else if let single = configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordName + ) { + recordNames = [single] + } else { + recordNames = [] + } + + guard !recordNames.isEmpty else { + throw LookupError.recordNamesRequired + } + + let fieldsString = configReader.string( + forKey: MistDemoConstants.ConfigKeys.fields + ) + let fields = fieldsString? + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + let outputString = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + recordNames: recordNames, + fields: fields, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift new file mode 100644 index 00000000..c1542073 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift @@ -0,0 +1,93 @@ +// +// LookupZonesConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation + +/// Configuration for lookup-zones command. +public struct LookupZonesConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The zone names to look up. + public let zoneNames: [String] + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zoneNames: [String], + output: OutputFormat = .table + ) { + self.base = base + self.zoneNames = zoneNames + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let zoneNamesString = + configuration.string( + forKey: "zone.names", + default: "_defaultZone" + ) ?? "_defaultZone" + let zoneNames = zoneNamesString.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces) + } + + let outputString = + configuration.string(forKey: "output.format", default: "table") + ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + zoneNames: zoneNames, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift new file mode 100644 index 00000000..243e5f58 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -0,0 +1,126 @@ +// +// MistDemoConfig+DatabaseConfiguration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +extension MistDemoConfig { + /// Indicates whether `toPrimaryCredentials()` will produce credentials that + /// can satisfy user-identity endpoints (`fetchCaller`, `lookupUsers*`). + /// + /// Those routes require web-auth even on `.public`. Used by the integration + /// runner to decide whether to schedule user-identity phases. + internal var hasUserContextCredentials: Bool { + (try? resolveAPICredentials()) != nil + } + + /// Build `Credentials` for the primary `CloudKitService` targeting + /// `self.database`. + /// + /// - `.public`: requires server-to-server material (`keyID` + + /// `privateKey`/`privateKeyFile`). If web-auth env vars are also set, + /// they're populated alongside so the same `Credentials` can back a + /// user-context service. + /// - `.private` / `.shared`: requires `apiToken` + `webAuthToken`. + /// + /// - Throws: `ConfigurationError.missingRequired` if any required field for + /// the chosen database is missing or empty. + internal func toPrimaryCredentials() throws -> Credentials { + switch database { + case .public: + let s2s = try resolveServerToServerCredentials() + // Optional: also include web-auth so a single service can serve + // user-identity routes (`fetchCaller`, `lookupUsers*`) alongside + // S2S-signed record operations on `.public`. + let webAuth: APICredentials? + do { + webAuth = try resolveAPICredentials() + } catch { + webAuth = nil + let line = + "INFO: Public-DB credentials resolved without web-auth — " + + "user-identity routes (fetchCaller, lookupUsers*) will be unavailable. " + + "Underlying: \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + return try Credentials(serverToServer: s2s, apiAuth: webAuth) + case .private, .shared: + let apiAuth = try resolveAPICredentials() + return try Credentials(apiAuth: apiAuth) + } + } + + // MARK: - Resolution helpers + + private func resolveServerToServerCredentials() throws -> ServerToServerCredentials { + guard let keyID, !keyID.isEmpty else { + throw ConfigurationError.missingRequired( + "key.id", + suggestion: "Provide via CLOUDKIT_KEY_ID environment variable" + ) + } + let material = try resolvePrivateKeyMaterial() + return ServerToServerCredentials(keyID: keyID, privateKey: material) + } + + private func resolvePrivateKeyMaterial() throws -> PrivateKeyMaterial { + if let raw = privateKey, !raw.isEmpty { + return .raw(raw) + } else if let path = privateKeyFile, !path.isEmpty { + return .file(path: path) + } + throw ConfigurationError.missingRequired( + "private.key", + suggestion: "Provide via CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH" + ) + } + + private func resolveAPICredentials() throws -> APICredentials { + let resolvedAPIToken = AuthenticationHelper.resolveAPIToken(apiToken) + guard !resolvedAPIToken.isEmpty else { + throw ConfigurationError.missingRequired( + "api.token", + suggestion: "Provide via CLOUDKIT_API_TOKEN environment variable" + ) + } + let resolvedWebAuth = webAuthToken.flatMap { + AuthenticationHelper.resolveWebAuthToken($0) + } + guard let resolvedWebAuth, !resolvedWebAuth.isEmpty else { + throw ConfigurationError.missingRequired( + "web.auth.token", + suggestion: "Provide via CLOUDKIT_WEB_AUTH_TOKEN or run `mistdemo auth-token`" + ) + } + return APICredentials( + apiToken: resolvedAPIToken, + webAuthToken: resolvedWebAuth + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift new file mode 100644 index 00000000..d88d4af5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift @@ -0,0 +1,169 @@ +// +// MistDemoConfig+Parsing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import MistKit + +extension MistDemoConfig { + internal struct CoreConfig { + internal let containerIdentifier: String + internal let apiToken: String + internal let environment: MistKit.Environment + } + + internal struct AuthConfig { + internal let webAuthToken: String? + internal let keyID: String? + internal let privateKey: String? + internal let privateKeyFile: String? + } + + internal struct ServerConfig { + internal let host: String + internal let port: Int + internal let authTimeout: Double + } + + internal struct FlagConfig { + internal let skipAuth: Bool + internal let testAllAuth: Bool + internal let testApiOnly: Bool + internal let testAdaptive: Bool + internal let testServerToServer: Bool + internal let badCredentials: Bool + } + + internal static func parseCoreConfig( + _ config: MistDemoConfiguration + ) throws -> CoreConfig { + let containerIdentifier = + config.string( + forKey: "container.identifier", + default: MistDemoConstants.Defaults.containerIdentifier + ) ?? MistDemoConstants.Defaults.containerIdentifier + + let apiToken = + config.string( + forKey: "api.token", + default: "", + isSecret: true + ) ?? "" + + let defaultEnv = MistKit.Environment.development.rawValue + let envString = + config.string(forKey: "environment", default: defaultEnv) ?? defaultEnv + guard let environment = MistKit.Environment(caseInsensitive: envString) else { + throw ConfigurationError.invalidEnvironment(envString) + } + + return CoreConfig( + containerIdentifier: containerIdentifier, + apiToken: apiToken, + environment: environment + ) + } + + internal static func parseAuthConfig( + _ config: MistDemoConfiguration + ) -> AuthConfig { + AuthConfig( + webAuthToken: config.string( + forKey: "web.auth.token", + isSecret: true + ), + keyID: config.string(forKey: "key.id"), + privateKey: config.string( + forKey: "private.key", + isSecret: true + ), + privateKeyFile: config.string( + forKey: "private.key.path" + ) + ) + } + + internal static func parseServerConfig( + _ config: MistDemoConfiguration + ) -> ServerConfig { + let host = + config.string( + forKey: "host", + default: "127.0.0.1" + ) ?? "127.0.0.1" + + let port = + config.int( + forKey: "port", + default: 8_080 + ) ?? 8_080 + + let authTimeout = Double( + config.int( + forKey: "auth.timeout", + default: 300 + ) ?? 300 + ) + + return ServerConfig( + host: host, + port: port, + authTimeout: authTimeout + ) + } + + internal static func parseFlags( + _ config: MistDemoConfiguration + ) -> FlagConfig { + FlagConfig( + skipAuth: config.bool( + forKey: "skip.auth", + default: false + ), + testAllAuth: config.bool( + forKey: "test.all.auth", + default: false + ), + testApiOnly: config.bool( + forKey: "test.api.only", + default: false + ), + testAdaptive: config.bool( + forKey: "test.adaptive", + default: false + ), + testServerToServer: config.bool( + forKey: "test.server.to.server", + default: false + ), + badCredentials: config.bool( + forKey: "bad.credentials", + default: false + ) + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift new file mode 100644 index 00000000..64fe379a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -0,0 +1,215 @@ +// +// MistDemoConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Configuration +import Foundation +public import MistKit + +/// Centralized configuration for MistDemo. +public struct MistDemoConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = Never + + // MARK: - CloudKit Core Configuration + + /// CloudKit container identifier. + internal let containerIdentifier: String + /// CloudKit API token (secret). + internal let apiToken: String + /// CloudKit environment (development or production). + internal let environment: MistKit.Environment + /// CloudKit database (public, private, or shared). + internal let database: MistKit.Database + + // MARK: - Authentication Configuration + + /// Web authentication token (secret). + internal let webAuthToken: String? + /// Server-to-server key ID. + internal let keyID: String? + /// Server-to-server private key (secret). + internal let privateKey: String? + /// Path to server-to-server private key file. + internal let privateKeyFile: String? + + // MARK: - Server Configuration + + /// Server host for authentication. + internal let host: String + /// Server port for authentication. + internal let port: Int + /// Authentication timeout in seconds. + internal let authTimeout: Double + + // MARK: - Test Flags + + /// Skip authentication and use provided token directly. + internal let skipAuth: Bool + /// Test all authentication methods. + internal let testAllAuth: Bool + /// Test API-only authentication. + internal let testApiOnly: Bool + /// Test AdaptiveTokenManager transitions. + internal let testAdaptive: Bool + /// Test server-to-server authentication. + internal let testServerToServer: Bool + + // MARK: - Demo Flags + + /// Use deliberately invalid credentials (for the talk's 401 demo). + internal let badCredentials: Bool + + // MARK: - Initialization + + /// Initialize with Swift Configuration's hierarchical providers. + public init( + configuration: MistDemoConfiguration, + base: Never? = nil + ) async throws { + let config = configuration + let core = try Self.parseCoreConfig(config) + self.containerIdentifier = core.containerIdentifier + self.apiToken = core.apiToken + self.environment = core.environment + + let databaseString = + config.string(forKey: "database", default: "public") ?? "public" + guard let database = MistDemoConfig.parseDatabase(databaseString) else { + throw ConfigurationError.invalidDatabase(databaseString) + } + self.database = database + + let auth = Self.parseAuthConfig(config) + self.webAuthToken = auth.webAuthToken + self.keyID = auth.keyID + self.privateKey = auth.privateKey + self.privateKeyFile = auth.privateKeyFile + + let server = Self.parseServerConfig(config) + self.host = server.host + self.port = server.port + self.authTimeout = server.authTimeout + + let flags = Self.parseFlags(config) + self.skipAuth = flags.skipAuth + self.testAllAuth = flags.testAllAuth + self.testApiOnly = flags.testApiOnly + self.testAdaptive = flags.testAdaptive + self.testServerToServer = flags.testServerToServer + self.badCredentials = flags.badCredentials + } + + /// Memberwise initializer used internally for overrides. + internal init( + containerIdentifier: String, + apiToken: String, + environment: MistKit.Environment, + database: MistKit.Database, + webAuthToken: String?, + keyID: String?, + privateKey: String?, + privateKeyFile: String?, + host: String, + port: Int, + authTimeout: Double, + skipAuth: Bool, + testAllAuth: Bool, + testApiOnly: Bool, + testAdaptive: Bool, + testServerToServer: Bool, + badCredentials: Bool + ) { + self.containerIdentifier = containerIdentifier + self.apiToken = apiToken + self.environment = environment + self.database = database + self.webAuthToken = webAuthToken + self.keyID = keyID + self.privateKey = privateKey + self.privateKeyFile = privateKeyFile + self.host = host + self.port = port + self.authTimeout = authTimeout + self.skipAuth = skipAuth + self.testAllAuth = testAllAuth + self.testApiOnly = testApiOnly + self.testAdaptive = testAdaptive + self.testServerToServer = testServerToServer + self.badCredentials = badCredentials + } + + /// Map a `"public" | "private" | "shared"` string to a `MistKit.Database`. + /// + /// `"public"` resolves to `.public(.prefers(.serverToServer))` to match + /// `toPrimaryCredentials()`'s "S2S-preferred, web-auth augments" policy. + /// Returns `nil` for unrecognized strings so callers can raise a + /// configuration error. + internal static func parseDatabase( + _ raw: String + ) -> MistKit.Database? { + switch raw { + case "public": + return .public(.prefers(.serverToServer)) + case "private": + return .private + case "shared": + return .shared + default: + return nil + } + } + + /// Returns a copy with the given database override. + internal func with( + database: MistKit.Database + ) -> MistDemoConfig { + MistDemoConfig( + containerIdentifier: containerIdentifier, + apiToken: apiToken, + environment: environment, + database: database, + webAuthToken: webAuthToken, + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile, + host: host, + port: port, + authTimeout: authTimeout, + skipAuth: skipAuth, + testAllAuth: testAllAuth, + testApiOnly: testApiOnly, + testAdaptive: testAdaptive, + testServerToServer: testServerToServer, + badCredentials: badCredentials + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift new file mode 100644 index 00000000..536402fe --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift @@ -0,0 +1,152 @@ +// +// MistDemoConfiguration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Configuration +import Foundation +import SystemPackage + +/// Swift Configuration-based setup for MistDemo. +public struct MistDemoConfiguration: Sendable { + // MARK: Private + + private let configReader: ConfigReader + + // MARK: Lifecycle + + /// Creates a new instance from environment and CLI providers. + public init() async throws { + let envProvider = try await EnvironmentVariablesProvider( + environmentFilePath: FilePath(".env"), + allowMissing: true + ) + + self.configReader = ConfigReader(providers: [ + // 1. Command line arguments (highest priority) + CommandLineArgumentsProvider(), + + // 2. Process environment variables (CLOUDKIT_ prefix) + EnvironmentVariablesProvider().prefixKeys(with: "cloudkit"), + + // 3. .env file variables (CLOUDKIT_ prefix) + envProvider.prefixKeys(with: "cloudkit"), + + // 4. In-memory defaults (lowest priority) + InMemoryProvider(values: [ + "port": 8_080, + "skip.auth": false, + "test.all.auth": false, + "test.api.only": false, + "test.adaptive": false, + "test.server.to.server": false, + ]), + ]) + } + + /// Internal initializer for testing with InMemoryProvider. + internal init(testProvider: InMemoryProvider) { + self.configReader = ConfigReader(providers: [ + testProvider + ]) + } + + // MARK: Public + + /// Read string value with hierarchy: CLI -> ENV -> defaults. + public func string( + forKey key: String, + default defaultValue: String? = nil, + isSecret: Bool = false + ) -> String? { + if let defaultValue = defaultValue { + return configReader.string( + forKey: Configuration.ConfigKey(key), + isSecret: isSecret, + default: defaultValue + ) + } else { + return configReader.string( + forKey: Configuration.ConfigKey(key), + isSecret: isSecret + ) + } + } + + /// Read required string value. + public func requiredString( + forKey key: String, + isSecret: Bool = false + ) throws -> String { + try configReader.requiredString( + forKey: Configuration.ConfigKey(key), + isSecret: isSecret + ) + } + + /// Read int value with hierarchy. + public func int( + forKey key: String, + default defaultValue: Int? = nil + ) -> Int? { + if let defaultValue = defaultValue { + return configReader.int( + forKey: Configuration.ConfigKey(key), + default: defaultValue + ) + } else { + return configReader.int( + forKey: Configuration.ConfigKey(key) + ) + } + } + + /// Read required int value. + public func requiredInt(forKey key: String) throws -> Int { + try configReader.requiredInt( + forKey: Configuration.ConfigKey(key) + ) + } + + /// Read bool value with hierarchy. + public func bool( + forKey key: String, + default defaultValue: Bool = false + ) -> Bool { + configReader.bool( + forKey: Configuration.ConfigKey(key), + default: defaultValue + ) + } + + /// Read a pipe-separated list of strings from configuration. + public func filterStrings(forKey key: String) -> [String] { + string(forKey: key)? + .split(separator: "|") + .map { String($0).trimmingCharacters(in: .whitespaces) } ?? [] + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift new file mode 100644 index 00000000..b7311854 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift @@ -0,0 +1,155 @@ +// +// ModifyConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +public import Foundation +public import MistKit + +/// Configuration for modify command. +public struct ModifyConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The list of modify operations to perform. + public let operations: [ModifyOperationInput] + /// Whether to perform operations atomically. + public let atomic: Bool + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + operations: [ModifyOperationInput], + atomic: Bool = false, + output: OutputFormat = .json + ) { + self.base = base + self.operations = operations + self.atomic = atomic + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let operations = try Self.parseOperationsFromSources( + configReader + ) + + let atomic = configReader.bool( + forKey: MistDemoConstants.ConfigKeys.atomic, + default: false + ) + + let outputString = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + operations: operations, + atomic: atomic, + output: output + ) + } + + /// Parse a JSON array of operations from data. + public static func parseOperations( + from data: Data + ) throws -> [ModifyOperationInput] { + do { + return try JSONDecoder().decode( + [ModifyOperationInput].self, + from: data + ) + } catch let DecodingError.dataCorrupted(context) where context.codingPath.isEmpty { + throw ModifyError.stdinError(context.debugDescription) + } catch let error as ModifyError { + throw error + } catch { + throw ModifyError.stdinError(error.localizedDescription) + } + } + + private static func parseOperationsFromSources( + _ configReader: MistDemoConfiguration + ) throws -> [ModifyOperationInput] { + if let path = configReader.string( + forKey: MistDemoConstants.ConfigKeys.operationsFile + ) { + do { + let data = try Data( + contentsOf: URL(fileURLWithPath: path) + ) + return try parseOperations(from: data) + } catch let error as ModifyError { + throw error + } catch { + throw ModifyError.operationsFileError( + path, + error.localizedDescription + ) + } + } + + if configReader.bool( + forKey: MistDemoConstants.ConfigKeys.stdin, + default: false + ) { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + guard !stdinData.isEmpty else { + throw ModifyError.emptyStdin + } + return try parseOperations(from: stdinData) + } + + throw ModifyError.operationsRequired + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyOperationInput.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyOperationInput.swift new file mode 100644 index 00000000..86376338 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyOperationInput.swift @@ -0,0 +1,113 @@ +// +// ModifyOperationInput.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +/// One operation parsed from the modify ops JSON payload. +public struct ModifyOperationInput: Codable, Sendable { + private enum CodingKeys: String, CodingKey { + case operation = "op" + case recordType + case recordName + case fields + case recordChangeTag + } + + /// The operation kind (create, update, or delete). + public let operation: ModifyOperationKind + /// The CloudKit record type. + public let recordType: String + /// The optional record name. + public let recordName: String? + /// The optional fields to set. + public let fields: FieldsInput? + /// The optional record change tag for conflict detection. + public let recordChangeTag: String? + + /// Creates a new instance. + public init( + operation: ModifyOperationKind, + recordType: String, + recordName: String? = nil, + fields: FieldsInput? = nil, + recordChangeTag: String? = nil + ) { + self.operation = operation + self.recordType = recordType + self.recordName = recordName + self.fields = fields + self.recordChangeTag = recordChangeTag + } + + /// Convert this operation input into a MistKit RecordOperation. + public func toRecordOperation(index: Int) throws -> RecordOperation { + let cloudKitFields: [String: FieldValue] + if let fields { + let domainFields = try fields.toFields() + cloudKitFields = try domainFields.toCloudKitFields() + } else { + cloudKitFields = [:] + } + + switch operation { + case .create: + return RecordOperation.create( + recordType: recordType, + recordName: recordName, + fields: cloudKitFields + ) + case .update: + guard let recordName else { + throw ModifyError.missingRecordName( + opIndex: index, + operation: operation.rawValue + ) + } + return RecordOperation.update( + recordType: recordType, + recordName: recordName, + fields: cloudKitFields, + recordChangeTag: recordChangeTag + ) + case .delete: + guard let recordName else { + throw ModifyError.missingRecordName( + opIndex: index, + operation: operation.rawValue + ) + } + return RecordOperation.delete( + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyOperationKind.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyOperationKind.swift new file mode 100644 index 00000000..8dcf1287 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyOperationKind.swift @@ -0,0 +1,35 @@ +// +// ModifyOperationKind.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Operation type from the JSON ops file. +public enum ModifyOperationKind: String, Codable, Sendable { + case create + case update + case delete +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift new file mode 100644 index 00000000..1e49f23b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift @@ -0,0 +1,157 @@ +// +// QueryConfig+Parsing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension QueryConfig { + internal struct ParsedPagination { + internal let limit: Int + internal let offset: Int + internal let fields: [String]? + internal let continuationMarker: String? + internal let output: OutputFormat + } + + internal struct ParsedOptions { + internal let zone: String + internal let recordType: String + internal let filters: [String] + internal let sort: (field: String, order: SortOrder)? + internal let pagination: ParsedPagination + } + + internal static func parseAllOptions( + _ configReader: MistDemoConfiguration + ) throws -> ParsedOptions { + let zone = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.zone, + default: MistDemoConstants.Defaults.zone + ) ?? MistDemoConstants.Defaults.zone + let recordType = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordType, + default: MistDemoConstants.Defaults.recordType + ) ?? MistDemoConstants.Defaults.recordType + + let filters = configReader.filterStrings( + forKey: MistDemoConstants.ConfigKeys.filter + ) + + let sortString = configReader.string( + forKey: MistDemoConstants.ConfigKeys.sort + ) + let sort = try parseSortOption(sortString) + + let pagination = try parsePagination(configReader) + + return ParsedOptions( + zone: zone, + recordType: recordType, + filters: filters, + sort: sort, + pagination: pagination + ) + } + + private static func parsePagination( + _ configReader: MistDemoConfiguration + ) throws -> ParsedPagination { + let limit = + configReader.int( + forKey: MistDemoConstants.ConfigKeys.limit, + default: MistDemoConstants.Defaults.queryLimit + ) ?? MistDemoConstants.Defaults.queryLimit + guard + limit >= MistDemoConstants.Limits.minQueryLimit, + limit <= MistDemoConstants.Limits.maxQueryLimit + else { + throw QueryError.invalidLimit(limit) + } + + let offset = + configReader.int(forKey: "offset", default: 0) ?? 0 + + let fieldsString = configReader.string( + forKey: MistDemoConstants.ConfigKeys.fields + ) + let fields = fieldsString?.split(separator: ",").map { + String($0).trimmingCharacters(in: .whitespaces) + } + + let continuationMarker = configReader.string( + forKey: "continuation.marker" + ) + + let outputString = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + return ParsedPagination( + limit: limit, + offset: offset, + fields: Array(fields ?? []), + continuationMarker: continuationMarker, + output: output + ) + } + + private static func parseSortOption( + _ sortString: String? + ) throws -> (field: String, order: SortOrder)? { + guard let sortString = sortString, !sortString.isEmpty else { + return nil + } + + let components = sortString.split(separator: ":", maxSplits: 1) + guard components.count >= 1 else { + return nil + } + + let field = String(components[0]).trimmingCharacters( + in: .whitespaces + ) + let orderString = + components.count > 1 + ? String(components[1]).trimmingCharacters(in: .whitespaces) + : "asc" + + guard let order = SortOrder(rawValue: orderString.lowercased()) else { + throw QueryError.invalidSortOrder( + orderString, + available: SortOrder.allCases.map(\.rawValue) + ) + } + + return (field: field, order: order) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift new file mode 100644 index 00000000..813dd081 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -0,0 +1,118 @@ +// +// QueryConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for query command. +public struct QueryConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The CloudKit zone name. + public let zone: String + /// The CloudKit record type. + public let recordType: String + /// The filter expressions. + public let filters: [String] + /// The optional sort field and order. + public let sort: (field: String, order: SortOrder)? + /// The maximum number of records to return. + public let limit: Int + /// The result offset for pagination. + public let offset: Int + /// The optional field names to include in the response. + public let fields: [String]? + /// The optional continuation marker for pagination. + public let continuationMarker: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zone: String = "_defaultZone", + recordType: String = "Note", + filters: [String] = [], + sort: (field: String, order: SortOrder)? = nil, + limit: Int = 20, + offset: Int = 0, + fields: [String]? = nil, + continuationMarker: String? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.zone = zone + self.recordType = recordType + self.filters = filters + self.sort = sort + self.limit = limit + self.offset = offset + self.fields = fields + self.continuationMarker = continuationMarker + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base = base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let parsed = try Self.parseAllOptions(configReader) + + self.init( + base: baseConfig, + zone: parsed.zone, + recordType: parsed.recordType, + filters: parsed.filters, + sort: parsed.sort, + limit: parsed.pagination.limit, + offset: parsed.pagination.offset, + fields: parsed.pagination.fields, + continuationMarker: parsed.pagination.continuationMarker, + output: parsed.pagination.output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Configuration/SortOrder.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/SortOrder.swift similarity index 95% rename from Examples/MistDemo/Sources/MistDemo/Configuration/SortOrder.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/SortOrder.swift index fe6083a3..09609830 100644 --- a/Examples/MistDemo/Sources/MistDemo/Configuration/SortOrder.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/SortOrder.swift @@ -29,6 +29,6 @@ /// Sort order for query operations public enum SortOrder: String, CaseIterable, Sendable { - case ascending = "asc" - case descending = "desc" -} \ No newline at end of file + case ascending = "asc" + case descending = "desc" +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift new file mode 100644 index 00000000..d4a70ba6 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -0,0 +1,119 @@ +// +// TestPrivateConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import MistKit + +/// Configuration for test-private command (private database). +public struct TestPrivateConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The number of records to create during testing. + public let recordCount: Int + /// The asset size in kilobytes for upload testing. + public let assetSizeKB: Int + /// Whether to skip cleanup after testing. + public let skipCleanup: Bool + /// Whether to enable verbose output. + public let verbose: Bool + /// Optional email used by the lookup-users-by-email phase. Must belong to + /// an iCloud account discoverable to the caller; otherwise the phase skips. + public let lookupEmail: String? + + /// Creates a new instance. + public init( + base: MistDemoConfig, + recordCount: Int = 10, + assetSizeKB: Int = 100, + skipCleanup: Bool = false, + verbose: Bool = false, + lookupEmail: String? = nil + ) { + self.base = base + self.recordCount = recordCount + self.assetSizeKB = assetSizeKB + self.skipCleanup = skipCleanup + self.verbose = verbose + self.lookupEmail = lookupEmail + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let parsedBase: MistDemoConfig + if let base { + parsedBase = base + } else { + parsedBase = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + // test-private's identity is "private database test" — pin + // the database regardless of any --database flag the user supplied. + let baseConfig = parsedBase.with(database: .private) + + guard + let webAuthToken = baseConfig.webAuthToken, + !webAuthToken.isEmpty + else { + throw ConfigurationError.missingRequired( + "web.auth.token", + suggestion: + "Provide via CLOUDKIT_WEB_AUTH_TOKEN or run `mistdemo auth-token`" + ) + } + + let recordCount = + configuration.int(forKey: "record.count", default: 10) ?? 10 + let assetSizeKB = + configuration.int(forKey: "asset.size", default: 100) ?? 100 + let skipCleanup = + configuration.bool(forKey: "skip.cleanup", default: false) + let verbose = + configuration.bool(forKey: "verbose", default: false) + let lookupEmail = configuration.string(forKey: "lookup.email") + + self.init( + base: baseConfig, + recordCount: recordCount, + assetSizeKB: assetSizeKB, + skipCleanup: skipCleanup, + verbose: verbose, + lookupEmail: lookupEmail + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift new file mode 100644 index 00000000..86b663c5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift @@ -0,0 +1,104 @@ +// +// TestPublicConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit + +/// Configuration for test-public command. +public struct TestPublicConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The number of records to create during testing. + public let recordCount: Int + /// The asset size in kilobytes for upload testing. + public let assetSizeKB: Int + /// Whether to skip cleanup after testing. + public let skipCleanup: Bool + /// Whether to enable verbose output. + public let verbose: Bool + /// Optional email used by the lookup-users-by-email phase. Must belong to + /// an iCloud account discoverable to the caller; otherwise the phase skips. + public let lookupEmail: String? + + /// Creates a new instance. + public init( + base: MistDemoConfig, + recordCount: Int = 10, + assetSizeKB: Int = 100, + skipCleanup: Bool = false, + verbose: Bool = false, + lookupEmail: String? = nil + ) { + self.base = base + self.recordCount = recordCount + self.assetSizeKB = assetSizeKB + self.skipCleanup = skipCleanup + self.verbose = verbose + self.lookupEmail = lookupEmail + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let recordCount = + configuration.int(forKey: "record.count", default: 10) ?? 10 + let assetSizeKB = + configuration.int(forKey: "asset.size", default: 100) ?? 100 + let skipCleanup = + configuration.bool(forKey: "skip.cleanup", default: false) + let verbose = + configuration.bool(forKey: "verbose", default: false) + let lookupEmail = configuration.string(forKey: "lookup.email") + + self.init( + base: baseConfig, + recordCount: recordCount, + assetSizeKB: assetSizeKB, + skipCleanup: skipCleanup, + verbose: verbose, + lookupEmail: lookupEmail + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift new file mode 100644 index 00000000..6597bdcf --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift @@ -0,0 +1,220 @@ +// +// UpdateConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for update command. +public struct UpdateConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The CloudKit zone name. + public let zone: String + /// The CloudKit record type. + public let recordType: String + /// The record name to update. + public let recordName: String + /// The optional record change tag for conflict detection. + public let recordChangeTag: String? + /// Whether to force update without change tag. + public let force: Bool + /// The fields to update. + public let fields: [Field] + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zone: String = "_defaultZone", + recordType: String = "Note", + recordName: String, + recordChangeTag: String? = nil, + force: Bool = false, + fields: [Field] = [], + output: OutputFormat = .json + ) { + self.base = base + self.zone = zone + self.recordType = recordType + self.recordName = recordName + self.recordChangeTag = recordChangeTag + self.force = force + self.fields = fields + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base = base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + // Parse update-specific options + let zone = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.zone, + default: MistDemoConstants.Defaults.zone + ) ?? MistDemoConstants.Defaults.zone + let recordType = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordType, + default: MistDemoConstants.Defaults.recordType + ) ?? MistDemoConstants.Defaults.recordType + + // Validate recordName is provided (REQUIRED for update) + guard + let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) + else { + throw UpdateError.recordNameRequired + } + + let recordChangeTag = configReader.string( + forKey: MistDemoConstants.ConfigKeys.recordChangeTag + ) + let force = configReader.bool( + forKey: MistDemoConstants.ConfigKeys.force, + default: false + ) + + // Parse fields from various sources + let fields = try Self.parseFieldsFromSources(configReader) + + // Parse output format + let outputString = + configReader.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + zone: zone, + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag, + force: force, + fields: fields, + output: output + ) + } + + private static func parseFieldsFromSources( + _ configReader: MistDemoConfiguration + ) throws -> [Field] { + var fields: [Field] = [] + + // 1. Parse inline field definitions + if let fieldString = configReader.string(forKey: "field") { + let fieldDefinitions = fieldString.split(separator: ",").map { + String($0).trimmingCharacters(in: .whitespaces) + } + let inlineFields = try Field.parseFields(fieldDefinitions) + fields.append(contentsOf: inlineFields) + } + + // 2. Parse from JSON file + if let jsonFile = configReader.string( + forKey: MistDemoConstants.ConfigKeys.jsonFile + ) { + let jsonFields = try parseFieldsFromJSONFile(jsonFile) + fields.append(contentsOf: jsonFields) + } + + // 3. Parse from stdin (check if data is available) + if configReader.bool( + forKey: MistDemoConstants.ConfigKeys.stdin, + default: false + ) { + let stdinFields = try parseFieldsFromStdin() + fields.append(contentsOf: stdinFields) + } + + guard !fields.isEmpty else { + throw UpdateError.noFieldsProvided + } + + return fields + } + + /// Parse fields from JSON file. + private static func parseFieldsFromJSONFile( + _ filePath: String + ) throws -> [Field] { + do { + let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) + let fieldsInput = try JSONDecoder().decode( + FieldsInput.self, + from: data + ) + return try fieldsInput.toFields() + } catch { + throw UpdateError.jsonFileError( + filePath, + error.localizedDescription + ) + } + } + + /// Parse fields from stdin. + private static func parseFieldsFromStdin() throws -> [Field] { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + + guard !stdinData.isEmpty else { + throw UpdateError.emptyStdin + } + + do { + let fieldsInput = try JSONDecoder().decode( + FieldsInput.self, + from: stdinData + ) + return try fieldsInput.toFields() + } catch { + throw UpdateError.stdinError(error.localizedDescription) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift new file mode 100644 index 00000000..4b38d8c5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift @@ -0,0 +1,118 @@ +// +// UploadAssetConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for upload-asset command. +public struct UploadAssetConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The file path to upload. + public let file: String + /// The CloudKit record type. + public let recordType: String + /// The field name for the asset. + public let fieldName: String + /// The optional record name. + public let recordName: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + file: String, + recordType: String, + fieldName: String, + recordName: String? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.file = file + self.recordType = recordType + self.fieldName = fieldName + self.recordName = recordName + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let configReader = configuration + let baseConfig: MistDemoConfig + if let base = base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + // Get file path from configuration + guard let filePath = configReader.string(forKey: "file") else { + throw UploadAssetError.filePathRequired + } + + // Get record type (defaults to "Note") + let recordType = + configReader.string(forKey: "record-type") ?? "Note" + + // Get field name (defaults to "image") + let fieldName = + configReader.string(forKey: "field-name") ?? "image" + + // Parse optional record name + let recordName = configReader.string(forKey: "record-name") + + // Parse output format + let outputString = + configReader.string(forKey: "output.format", default: "json") + ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + file: filePath, + recordType: recordType, + fieldName: fieldName, + recordName: recordName, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift new file mode 100644 index 00000000..8103853e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -0,0 +1,164 @@ +// +// WebConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for the long-running `web` demo command. +/// +/// Pairs the same auth-flow inputs as `AuthTokenConfig` with the CloudKit +/// environment so the server can build a `CloudKitService` after the user +/// completes the browser-side auth round trip. If server-to-server key +/// material is also supplied (`keyID` + either `privateKey` or +/// `privateKeyFile`), the demo additionally enables the public database +/// path so the UI can compare web-auth vs S2S signing side-by-side. +public struct WebConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = Never + + /// The CloudKit API token. + public let apiToken: String + /// The CloudKit container identifier. + public let containerIdentifier: String + /// The CloudKit environment (development / production). + public let environment: MistKit.Environment + /// The server port. + public let port: Int + /// The server host. + public let host: String + /// Whether to open the browser to the demo URL on startup. + /// Defaults to `false` for `web` — the long-running server is often + /// driven from another machine (or a non-default browser), so silent + /// startup is the safer UX. Override with `--browser`. + public let openBrowser: Bool + /// Server-to-server key identifier (optional). When paired with + /// `privateKey` or `privateKeyFile`, unlocks the public-database path. + public let keyID: String? + /// Server-to-server private key material (optional, secret). + public let privateKey: String? + /// Path to a server-to-server private key file (optional). + public let privateKeyFile: String? + + /// Whether the configuration carries the credentials needed to target + /// the public database via server-to-server signing. + public var publicDatabaseAvailable: Bool { + guard let keyID, !keyID.isEmpty else { + return false + } + let hasInlineKey = (privateKey?.isEmpty == false) + let hasKeyFile = (privateKeyFile?.isEmpty == false) + return hasInlineKey || hasKeyFile + } + + /// Creates a new instance. + public init( + apiToken: String, + containerIdentifier: String = MistDemoConstants.Defaults.containerIdentifier, + environment: MistKit.Environment = .development, + port: Int = 8_080, + host: String = "127.0.0.1", + openBrowser: Bool = false, + keyID: String? = nil, + privateKey: String? = nil, + privateKeyFile: String? = nil + ) { + self.apiToken = apiToken + self.containerIdentifier = containerIdentifier + self.environment = environment + self.port = port + self.host = host + self.openBrowser = openBrowser + self.keyID = keyID + self.privateKey = privateKey + self.privateKeyFile = privateKeyFile + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: Never? = nil + ) async throws { + let configReader = configuration + + let apiToken = + configReader.string(forKey: "api.token", isSecret: true) ?? "" + guard !apiToken.isEmpty else { + throw ConfigurationError.missingRequired( + "api.token", + suggestion: + "Provide via --api-token or CLOUDKIT_API_TOKEN environment variable" + ) + } + + let containerIdentifier = + configReader.string( + forKey: "container.identifier", + default: MistDemoConstants.Defaults.containerIdentifier + ) ?? MistDemoConstants.Defaults.containerIdentifier + + let envString = + configReader.string(forKey: "environment", default: "development") + ?? "development" + guard let environment = MistKit.Environment(caseInsensitive: envString) else { + throw ConfigurationError.invalidEnvironment(envString) + } + + let port = + configReader.int(forKey: "port", default: 8_080) ?? 8_080 + let host = + configReader.string(forKey: "host", default: "127.0.0.1") + ?? "127.0.0.1" + let openBrowser = BrowserFlagResolver.resolve( + configReader: configReader, + default: false + ) + + let keyID = configReader.string(forKey: "key.id") + let privateKey = configReader.string( + forKey: "private.key", + isSecret: true + ) + let privateKeyFile = configReader.string(forKey: "private.key.path") + + self.init( + apiToken: apiToken, + containerIdentifier: containerIdentifier, + environment: environment, + port: port, + host: host, + openBrowser: openBrowser, + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift new file mode 100644 index 00000000..27e100cc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift @@ -0,0 +1,75 @@ +// +// MistDemoConstants+Defaults.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension MistDemoConstants { + /// Default values for configuration parameters. + public enum Defaults { + /// Default zone name. + public static let zone = "_defaultZone" + /// Default record type. + public static let recordType = "Note" + /// Default host address. + public static let host = "127.0.0.1" + /// Default port number. + public static let port = 8_080 + /// Default output format. + public static let outputFormat = "json" + /// Default query result limit. + public static let queryLimit = 20 + /// Default CloudKit environment. + public static let environment = "development" + /// Default CloudKit database. + public static let database = "private" + /// Default container identifier. + public static let containerIdentifier = + "iCloud.com.brightdigit.MistDemo" + } + + /// Numeric limits and ranges. + public enum Limits { + /// Minimum query limit. + public static let minQueryLimit = 1 + /// Maximum query limit. + public static let maxQueryLimit = 200 + /// Minimum random suffix value. + public static let randomSuffixMin = 1_000 + /// Maximum random suffix value. + public static let randomSuffixMax = 9_999 + } + + /// Timeout values in milliseconds. + public enum Timeouts { + /// Auth server timeout (5 minutes). + public static let authServer = 300_000 + /// Auth completion delay (1 second). + public static let authCompletionDelay = 1_000 + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift new file mode 100644 index 00000000..19eb5331 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift @@ -0,0 +1,97 @@ +// +// MistDemoConstants+Messages.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension MistDemoConstants { + /// User-facing messages. + public enum Messages { + /// Auth server starting message. + public static let authServerStarting = + "\u{1F680} Starting CloudKit Authentication Server" + /// Auth server URL format string. + public static let authServerURL = + "\u{1F4CD} Server URL: http://%@:%d" + /// Auth API token format string. + public static let authApiToken = + "\u{1F511} API Token: %@" + /// Auth serving files format string. + public static let authServingFiles = + "\u{1F4C1} Serving static files from: %@" + /// Auth opening browser message. + public static let authOpeningBrowser = + "\u{1F310} Opening browser..." + /// Auth browser disabled format string. + public static let authBrowserDisabled = + "\u{2139}\u{FE0F} Browser opening disabled." + + " Navigate to http://%@:%d manually" + /// Auth waiting message. + public static let authWaiting = + "\u{23F3} Waiting for authentication..." + /// Auth timeout message. + public static let authTimeout = " Timeout: 5 minutes" + /// Auth cancel message. + public static let authCancel = " Press Ctrl+C to cancel" + /// Auth success message. + public static let authSuccess = + "\u{2705} Authentication successful! Received token." + /// Auth success detail message. + public static let authSuccessMessage = + "Authentication successful! Token received." + + /// No records found message. + public static let noRecordsFound = "No records found" + /// Records found format string. + public static let recordsFound = "Found %d record(s)" + + /// Record created message. + public static let recordCreated = + "\u{2705} Record Created Successfully" + /// Creating record message. + public static let creatingRecord = "Creating record..." + + /// Missing API token error. + public static let missingAPIToken = "API token is required" + /// Missing web auth token error. + public static let missingWebAuthToken = + "Web auth token is required for private/shared databases" + /// Invalid limit error format string. + public static let invalidLimit = + "Invalid limit %d. Must be between %d and %d." + /// Invalid sort format error. + public static let invalidSortFormat = "Invalid sort format" + /// Invalid filter format error. + public static let invalidFilterFormat = + "Invalid filter format" + /// No fields provided error. + public static let noFieldsProvided = + "No fields provided." + + " Use --field, --json-file, or --stdin to specify fields." + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift new file mode 100644 index 00000000..d502dc34 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -0,0 +1,204 @@ +// +// MistDemoConstants.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Central constants for MistDemo application. +public enum MistDemoConstants { + // MARK: - Configuration Keys + + /// Configuration key names used throughout the application. + public enum ConfigKeys { + /// API token configuration key. + public static let apiToken = "api.token" + /// Web auth token configuration key. + public static let webAuthToken = "web.auth.token" + /// Container ID configuration key. + public static let containerID = "container.id" + /// Environment configuration key. + public static let environment = "environment" + /// Database configuration key. + public static let database = "database" + /// Record type configuration key. + public static let recordType = "record.type" + /// Record name configuration key. + public static let recordName = "record.name" + /// Zone configuration key. + public static let zone = "zone" + /// Limit configuration key. + public static let limit = "limit" + /// Fields configuration key. + public static let fields = "fields" + /// Output format configuration key. + public static let outputFormat = "output.format" + /// Sort configuration key. + public static let sort = "sort" + /// Filter configuration key. + public static let filter = "filter" + /// No-browser configuration key. + public static let noBrowser = "no.browser" + /// Host configuration key. + public static let host = "host" + /// Port configuration key. + public static let port = "port" + /// JSON file configuration key. + public static let jsonFile = "json.file" + /// Stdin configuration key. + public static let stdin = "stdin" + /// Record change tag configuration key. + public static let recordChangeTag = "record.change.tag" + /// Force configuration key. + public static let force = "force" + /// Record names configuration key. + public static let recordNames = "record.names" + /// Operations file configuration key. + public static let operationsFile = "operations.file" + /// Atomic configuration key. + public static let atomic = "atomic" + } + + // MARK: - Field Names + + /// Standard CloudKit field names. + public enum FieldNames { + /// Record name field. + public static let recordName = "recordName" + /// Record type field. + public static let recordType = "recordType" + /// Record change tag field. + public static let recordChangeTag = "recordChangeTag" + /// User record name field. + public static let userRecordName = "userRecordName" + /// First name field. + public static let firstName = "firstName" + /// Last name field. + public static let lastName = "lastName" + /// Email address field. + public static let emailAddress = "emailAddress" + /// Created timestamp field. + public static let created = "created" + /// Modified timestamp field. + public static let modified = "modified" + /// Record ID field. + public static let recordID = "recordID" + } + + // MARK: - CloudKit Parameters + + /// CloudKit API parameter names. + public enum CloudKitParams { + /// Query parameter. + public static let query = "query" + /// Zone ID parameter. + public static let zoneID = "zoneID" + /// Results limit parameter. + public static let resultsLimit = "resultsLimit" + /// Desired keys parameter. + public static let desiredKeys = "desiredKeys" + /// Sort-by parameter. + public static let sortBy = "sortBy" + /// Filter-by parameter. + public static let filterBy = "filterBy" + /// Continuation marker parameter. + public static let continuationMarker = "continuationMarker" + } + + // MARK: - API Paths + + /// API endpoint paths. + public enum APIPaths { + /// API base path. + public static let api = "api" + /// Authenticate path. + public static let authenticate = "authenticate" + } + + // MARK: - Content Types + + /// HTTP content types. + public enum ContentTypes { + /// JSON content type. + public static let json = "application/json" + /// HTML content type. + public static let html = "text/html" + /// CSS content type. + public static let css = "text/css" + /// JavaScript content type. + public static let javascript = "application/javascript" + } + + // MARK: - Resource Files + + /// Resource file names. + public enum Resources { + /// Index HTML filename. + public static let indexHTML = "index.html" + /// Resources folder name. + public static let resourcesFolder = "Resources" + /// Sources folder name. + public static let sourcesFolder = "Sources" + /// MistDemo folder name. + public static let mistDemoFolder = "MistDemo" + } + + // MARK: - Command Names + + /// CLI command names. + public enum Commands { + /// Query command name. + public static let query = "query" + /// Create command name. + public static let create = "create" + /// Update command name. + public static let update = "update" + /// Current-user command name. + public static let currentUser = "current-user" + /// Auth-token command name. + public static let authToken = "auth-token" + } + + // MARK: - Environment Variables + + /// Environment variable names. + public enum EnvironmentVars { + /// CloudKit API token environment variable. + public static let cloudKitAPIToken = "CLOUDKIT_API_TOKEN" + /// CloudKit web auth token environment variable. + public static let cloudKitWebAuthToken = + "CLOUDKIT_WEB_AUTH_TOKEN" + /// CloudKit container ID environment variable. + public static let cloudKitContainerID = + "CLOUDKIT_CONTAINER_ID" + /// CloudKit environment environment variable. + public static let cloudKitEnvironment = + "CLOUDKIT_ENVIRONMENT" + /// CloudKit database environment variable. + public static let cloudKitDatabase = "CLOUDKIT_DATABASE" + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/AuthTokenError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/AuthTokenError.swift new file mode 100644 index 00000000..e6d8279c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/AuthTokenError.swift @@ -0,0 +1,51 @@ +// +// AuthTokenError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + public import Foundation + + /// Authentication-related errors for auth-token command. + public enum AuthTokenError: Error, LocalizedError { + case timeout(String) + case missingResource(String) + case serverError(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .timeout(let message): + return "Authentication timeout: \(message)" + case .missingResource(let resource): + return "Missing resource: \(resource)" + case .serverError(let message): + return "Server error: \(message)" + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/CreateError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/CreateError.swift new file mode 100644 index 00000000..9e3a0495 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/CreateError.swift @@ -0,0 +1,62 @@ +// +// CreateError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors specific to create command +public enum CreateError: Error, LocalizedError { + case noFieldsProvided + case invalidJSONFormat(String) + case jsonFileError(String, String) + case emptyStdin + case stdinError(String) + case fieldConversionError(String, FieldType, String, String) + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .noFieldsProvided: + return MistDemoConstants.Messages.noFieldsProvided + case .invalidJSONFormat(let message): + return "Invalid JSON format: \(message)" + case .jsonFileError(let file, let error): + return "Error reading JSON file '\(file)': \(error)" + case .emptyStdin: + return "Empty stdin provided. Expected JSON object with field definitions." + case .stdinError(let error): + return "Error reading from stdin: \(error)" + case .fieldConversionError(let name, let type, let value, let error): + return + "Failed to convert field '\(name)' of type '\(type.rawValue)' with value '\(value)': \(error)" + case .operationFailed(let message): + return "Create operation failed: \(message)" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/CurrentUserError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/CurrentUserError.swift similarity index 75% rename from Examples/MistDemo/Sources/MistDemo/Errors/CurrentUserError.swift rename to Examples/MistDemo/Sources/MistDemoKit/Errors/CurrentUserError.swift index ac1861b4..a9f6e402 100644 --- a/Examples/MistDemo/Sources/MistDemo/Errors/CurrentUserError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/CurrentUserError.swift @@ -31,15 +31,18 @@ public import Foundation /// Errors specific to current-user command public enum CurrentUserError: Error, LocalizedError { - case operationFailed(String) - case authenticationRequired + case operationFailed(String) + case authenticationRequired - public var errorDescription: String? { - switch self { - case .operationFailed(let message): - return "Current user operation failed: \(message)" - case .authenticationRequired: - return "Authentication is required for current-user command. Use auth-token command first or provide --web-auth-token." - } + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .operationFailed(let message): + return "Current user operation failed: \(message)" + case .authenticationRequired: + return + "Authentication is required for current-user command." + + " Use auth-token command first or provide --web-auth-token." } + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/DeleteError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/DeleteError.swift new file mode 100644 index 00000000..3d4fbaa9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/DeleteError.swift @@ -0,0 +1,64 @@ +// +// DeleteError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during delete command execution +public enum DeleteError: Error, LocalizedError { + case recordNameRequired + case operationFailed(String) + case conflict(reason: String?) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .recordNameRequired: + return "Record name is required for delete operations. Use --record-name " + case .operationFailed(let reason): + return "Delete operation failed: \(reason)" + case .conflict(let reason): + if let reason { + return "Delete conflict: the record was modified on the server (\(reason))" + } + return "Delete conflict: the record was modified on the server" + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .recordNameRequired: + return "Specify a record name: mistdemo delete --record-name my-record-123" + case .conflict: + return "Re-run with --force to delete despite the change-tag mismatch." + case .operationFailed: + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/ErrorOutput+Convenience.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift similarity index 99% rename from Examples/MistDemo/Sources/MistDemo/Errors/ErrorOutput+Convenience.swift rename to Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift index 2e235677..acadabe8 100644 --- a/Examples/MistDemo/Sources/MistDemo/Errors/ErrorOutput+Convenience.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift @@ -41,4 +41,4 @@ extension ErrorOutput { let data = try encoder.encode(self) return String(decoding: data, as: UTF8.self) } -} \ No newline at end of file +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift new file mode 100644 index 00000000..f40e0be5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift @@ -0,0 +1,83 @@ +// +// ErrorOutput.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// JSON-formatted error output for consistent error reporting. +public struct ErrorOutput: Sendable, Codable { + // MARK: - Error Detail + + /// Detailed error information. + public struct ErrorDetail: Sendable, Codable { + /// Error code (machine-readable). + public let code: String + + /// Human-readable error message. + public let message: String + + /// Optional additional details about the error. + public let details: [String: String]? + + /// Optional suggestion for recovery. + public let suggestion: String? + + /// Create a new error detail. + public init( + code: String, + message: String, + details: [String: String]? = nil, + suggestion: String? = nil + ) { + self.code = code + self.message = message + self.details = details + self.suggestion = suggestion + } + } + + // MARK: Public + + /// The error details. + public let error: ErrorDetail + + /// Create a new error output. + public init( + code: String, + message: String, + details: [String: String]? = nil, + suggestion: String? = nil + ) { + self.error = ErrorDetail( + code: code, + message: message, + details: details, + suggestion: suggestion + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/FieldConversionError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift similarity index 85% rename from Examples/MistDemo/Sources/MistDemo/Errors/FieldConversionError.swift rename to Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift index 60ab799f..b9e26adc 100644 --- a/Examples/MistDemo/Sources/MistDemo/Errors/FieldConversionError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift @@ -28,17 +28,23 @@ // public import Foundation -public import MistKit +import MistKit /// Errors that can occur during field conversion public enum FieldConversionError: Error, LocalizedError { case conversionFailed(fieldName: String, fieldType: FieldType, value: String, reason: String) case invalidFieldValue(fieldType: FieldType, value: String) + /// A localized description of the error. public var errorDescription: String? { switch self { - case .conversionFailed(let fieldName, let fieldType, let value, let reason): - return "Failed to convert field '\(fieldName)' of type '\(fieldType.rawValue)' with value '\(value)': \(reason)" + case .conversionFailed( + let fieldName, let fieldType, let value, let reason + ): + return + "Failed to convert field '\(fieldName)'" + + " of type '\(fieldType.rawValue)'" + + " with value '\(value)': \(reason)" case .invalidFieldValue(let fieldType, let value): return "Unable to convert value '\(value)' to FieldValue for type '\(fieldType.rawValue)'" } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/LookupError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/LookupError.swift new file mode 100644 index 00000000..6d8ec20a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/LookupError.swift @@ -0,0 +1,56 @@ +// +// LookupError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during lookup command execution +public enum LookupError: Error, LocalizedError { + case recordNamesRequired + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .recordNamesRequired: + return "At least one record name is required. Use --record-names " + case .operationFailed(let reason): + return "Lookup operation failed: \(reason)" + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .recordNamesRequired: + return "Specify one or more record names: mistdemo lookup --record-names rec-1,rec-2" + case .operationFailed: + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/MistDemoError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift similarity index 76% rename from Examples/MistDemo/Sources/MistDemo/Errors/MistDemoError.swift rename to Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift index d7be2d82..268d77fa 100644 --- a/Examples/MistDemo/Sources/MistDemo/Errors/MistDemoError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift @@ -27,11 +27,11 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +import Foundation import MistKit -/// Comprehensive error type for MistDemo operations -enum MistDemoError: LocalizedError, Sendable { +/// Comprehensive error type for MistDemo operations. +internal enum MistDemoError: LocalizedError, Sendable { /// Authentication failed with underlying error case authenticationFailed(description: String, context: String) @@ -52,42 +52,42 @@ enum MistDemoError: LocalizedError, Sendable { /// Invalid format case invalidFormat(String) - + /// Unknown command case unknownCommand(String) // MARK: Public - var errorDescription: String? { + internal var errorDescription: String? { switch self { - case let .authenticationFailed(_, context): + case .authenticationFailed(_, let context): "Authentication failed: \(context)" - case let .configurationError(message, _): + case .configurationError(let message, _): "Configuration error: \(message)" - case let .cloudKitError(error, operation): + case .cloudKitError(let error, let operation): "CloudKit error during \(operation): \(error.localizedDescription)" - case let .invalidInput(field, value, reason): + case .invalidInput(let field, let value, let reason): "Invalid input for \(field) '\(value)': \(reason)" case .outputFormattingFailed: "Failed to format output" - case let .fileNotFound(path): + case .fileNotFound(let path): "File not found: \(path)" - case let .invalidFormat(message): + case .invalidFormat(let message): "Invalid format: \(message)" - case let .unknownCommand(command): + case .unknownCommand(let command): "Unknown command: \(command)" } } - var recoverySuggestion: String? { + internal var recoverySuggestion: String? { switch self { case .authenticationFailed: "Token may be expired. Run 'mistdemo auth' to sign in again." - case let .configurationError(_, suggestion): + case .configurationError(_, let suggestion): suggestion case .cloudKitError: "Check your CloudKit configuration and try again." - case let .invalidInput(field, _, _): + case .invalidInput(let field, _, _): "Provide a valid value for \(field)." case .outputFormattingFailed: "Try a different output format (--output json|table|csv|yaml)." @@ -100,8 +100,8 @@ enum MistDemoError: LocalizedError, Sendable { } } - /// Get the error code for machine-readable output - var errorCode: String { + /// Get the error code for machine-readable output. + internal var errorCode: String { switch self { case .authenticationFailed: "AUTHENTICATION_FAILED" @@ -122,30 +122,30 @@ enum MistDemoError: LocalizedError, Sendable { } } - /// Get error details for structured output - var errorDetails: [String: String] { + /// Get error details for structured output. + internal var errorDetails: [String: String] { switch self { - case let .authenticationFailed(_, context): + case .authenticationFailed(_, let context): ["context": context] case .configurationError: [:] - case let .cloudKitError(_, operation): + case .cloudKitError(_, let operation): ["operation": operation] - case let .invalidInput(field, value, reason): + case .invalidInput(let field, let value, let reason): ["field": field, "value": value, "reason": reason] case .outputFormattingFailed: [:] - case let .fileNotFound(path): + case .fileNotFound(let path): ["path": path] - case let .invalidFormat(message): + case .invalidFormat(let message): ["message": message] - case let .unknownCommand(command): + case .unknownCommand(let command): ["command": command] } } - /// Convert to structured ErrorOutput - var errorOutput: ErrorOutput { + /// Convert to structured ErrorOutput. + internal var errorOutput: ErrorOutput { ErrorOutput( code: errorCode, message: errorDescription ?? "Unknown error", diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ModifyError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ModifyError.swift new file mode 100644 index 00000000..5b6dca86 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ModifyError.swift @@ -0,0 +1,94 @@ +// +// ModifyError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during modify command execution +public enum ModifyError: Error, LocalizedError { + case operationsRequired + case operationsFileError(String, String) + case emptyStdin + case stdinError(String) + case invalidOperationType(String) + case missingRecordName(opIndex: Int, operation: String) + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .operationsRequired: + return + "No operations provided." + + " Use --operations-file or pipe JSON to stdin." + case .operationsFileError(let path, let reason): + return + "Failed to read operations file '\(path)': \(reason)" + case .emptyStdin: + return "Empty stdin. Provide a JSON array of operations." + case .stdinError(let reason): + return + "Failed to parse operations from stdin: \(reason)" + case .invalidOperationType(let opType): + return + "Unknown operation type '\(opType)'." + + " Use one of: create, update, delete." + case .missingRecordName(let index, let operation): + return + "Operation #\(index) (\(operation))" + + " is missing required 'recordName'." + case .operationFailed(let reason): + return "Modify operation failed: \(reason)" + } + } + + /// A localized recovery suggestion. + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .operationsRequired: + return "Provide a JSON array: --operations-file ops.json or echo '[...]' | mistdemo modify" + case .operationsFileError: + return "Ensure the file exists and contains a JSON array of operations." + case .emptyStdin: + return + "Pipe JSON: echo" + + " '[{\"op\":\"create\",\"recordType\":\"Note\"," + + "\"fields\":{\"title\":\"x\"}}]'" + + " | mistdemo modify" + case .stdinError: + return "Check the JSON syntax of the piped input." + case .invalidOperationType: + return "Set 'op' to 'create', 'update', or 'delete'." + case .missingRecordName: + return "Update and delete operations require a 'recordName'. Create may omit it." + case .operationFailed: + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Errors/OutputFormattingError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/OutputFormattingError.swift similarity index 78% rename from Examples/MistDemo/Sources/MistDemo/Errors/OutputFormattingError.swift rename to Examples/MistDemo/Sources/MistDemoKit/Errors/OutputFormattingError.swift index ab3a7bc9..e7dc1b34 100644 --- a/Examples/MistDemo/Sources/MistDemo/Errors/OutputFormattingError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/OutputFormattingError.swift @@ -31,15 +31,16 @@ public import Foundation /// Errors that can occur during output formatting public enum OutputFormattingError: Error, LocalizedError { - case encodingFailure(String) - case unsupportedType(String) - - public var errorDescription: String? { - switch self { - case .encodingFailure(let message): - return "Output encoding failed: \(message)" - case .unsupportedType(let type): - return "Output formatting not supported for type: \(type)" - } + case encodingFailure(String) + case unsupportedType(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .encodingFailure(let message): + return "Output encoding failed: \(message)" + case .unsupportedType(let type): + return "Output formatting not supported for type: \(type)" } -} \ No newline at end of file + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/QueryError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/QueryError.swift new file mode 100644 index 00000000..17a11529 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/QueryError.swift @@ -0,0 +1,71 @@ +// +// QueryError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors specific to query command. +public enum QueryError: Error, LocalizedError { + case invalidLimit(Int) + case invalidFilter(String, expected: String) + case emptyFieldName(String) + case invalidSortOrder(String, available: [String]) + case unsupportedOperator(String) + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .invalidLimit(let limit): + return String( + format: MistDemoConstants.Messages.invalidLimit, + limit, + MistDemoConstants.Limits.minQueryLimit, + MistDemoConstants.Limits.maxQueryLimit + ) + case .invalidFilter(let filter, let expected): + return + "Invalid filter '\(filter)'." + + " Expected format: \(expected)" + case .emptyFieldName(let filter): + return "Empty field name in filter '\(filter)'" + case .invalidSortOrder(let order, let available): + let list = available.joined(separator: ", ") + return + "Invalid sort order '\(order)'." + + " Available orders: \(list)" + case .unsupportedOperator(let opName): + return + "Unsupported filter operator '\(opName)'." + + " Supported: eq, ne, gt, gte, lt, lte," + + " contains, begins_with, in, not_in" + case .operationFailed(let message): + return "Query operation failed: \(message)" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/UpdateError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/UpdateError.swift new file mode 100644 index 00000000..09659f97 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/UpdateError.swift @@ -0,0 +1,105 @@ +// +// UpdateError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during update command execution +public enum UpdateError: Error, LocalizedError { + case recordNameRequired + case noFieldsProvided + case fieldConversionError(String, FieldType, String, String) + case jsonFileError(String, String) + case emptyStdin + case stdinError(String) + case operationFailed(String) + case conflict(reason: String?) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .recordNameRequired: + return "Record name is required for update operations. Use --record-name " + case .noFieldsProvided: + return "No fields provided. Use --field, --json-file, or --stdin to specify fields to update" + case .fieldConversionError( + let fieldName, let fieldType, let value, let reason + ): + return + "Failed to convert field '\(fieldName)'" + + " of type '\(fieldType.rawValue)'" + + " with value '\(value)': \(reason)" + case .jsonFileError(let filename, let reason): + return "Failed to read JSON file '\(filename)': \(reason)" + case .emptyStdin: + return "Empty stdin. Provide JSON data when using --stdin" + case .stdinError(let reason): + return "Failed to read from stdin: \(reason)" + case .operationFailed(let reason): + return "Update operation failed: \(reason)" + case .conflict(let reason): + if let reason { + return "Update conflict: the record was modified on the server (\(reason))" + } + return "Update conflict: the record was modified on the server" + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .recordNameRequired: + return + "Specify a record name: mistdemo update" + + " --record-name my-record-123" + + " --field \"title:string:Updated\"" + case .noFieldsProvided: + return + "Provide at least one field to update" + + " using --field, --json-file, or --stdin" + case .fieldConversionError: + return + "Check that the field value matches the" + + " expected type. Use --help for field type information" + case .jsonFileError: + return "Ensure the JSON file exists and contains valid JSON" + case .emptyStdin: + return + "Pipe JSON data to stdin:" + + " echo '{\"title\":\"Updated\"}'" + + " | mistdemo update --record-name my-record --stdin" + case .conflict: + return + "Re-run with --force to overwrite the server" + + " record, or fetch the current" + + " --record-change-tag and retry." + case .stdinError, .operationFailed: + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/UploadAssetError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/UploadAssetError.swift new file mode 100644 index 00000000..dfbfe9a0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/UploadAssetError.swift @@ -0,0 +1,70 @@ +// +// UploadAssetError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during asset upload operations +public enum UploadAssetError: Error, LocalizedError { + case filePathRequired + case recordTypeRequired + case fieldNameRequired + case fileNotFound(String) + case fileTooLarge(Int64, maximum: Int64) + case invalidRecordType(String) + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .filePathRequired: + return + "File path is required. Usage: mistdemo upload-asset" + + " --file --record-type " + + " --field-name " + case .recordTypeRequired: + return "Record type is required. Specify with --record-type " + case .fieldNameRequired: + return "Field name is required. Specify with --field-name " + case .fileNotFound(let path): + return "File not found at path: \(path)" + case .fileTooLarge(let size, let maximum): + let sizeMB = Double(size) / 1_024 / 1_024 + let maxMB = Double(maximum) / 1_024 / 1_024 + let sizeStr = String(format: "%.2f", sizeMB) + let maxStr = String(format: "%.2f", maxMB) + return + "File size (\(sizeStr) MB)" + + " exceeds maximum (\(maxStr) MB)" + case .invalidRecordType(let type): + return "Invalid record type: \(type)" + case .operationFailed(let message): + return "Upload operation failed: \(message)" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Extensions/Array+Field.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift similarity index 98% rename from Examples/MistDemo/Sources/MistDemo/Extensions/Array+Field.swift rename to Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift index bc46a08d..d82556fd 100644 --- a/Examples/MistDemo/Sources/MistDemo/Extensions/Array+Field.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +import Foundation public import MistKit extension Array where Element == Field { diff --git a/Examples/MistDemo/Sources/MistDemo/Extensions/Command+AnyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift similarity index 82% rename from Examples/MistDemo/Sources/MistDemo/Extensions/Command+AnyCommand.swift rename to Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift index e033abde..e422d33a 100644 --- a/Examples/MistDemo/Sources/MistDemo/Extensions/Command+AnyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift @@ -30,11 +30,12 @@ public import ConfigKeyKit import Foundation -/// Default implementation of createInstance for all MistDemo commands +/// Default implementation of createInstance for all MistDemo commands. extension Command where Config.ConfigReader == MistDemoConfiguration { - public static func createInstance() async throws -> Self { - let configuration = try MistDemoConfiguration() - let config = try await Config(configuration: configuration, base: nil) - return Self(config: config) - } + /// Create a new instance from the default configuration. + public static func createInstance() async throws -> Self { + let configuration = try await MistDemoConfiguration() + let config = try await Config(configuration: configuration, base: nil) + return Self(config: config) + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift new file mode 100644 index 00000000..a6a9ee7d --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift @@ -0,0 +1,61 @@ +// +// ConfigKey+MistDemo.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation + +// MARK: - MistDemo-Specific Config Key Helpers + +extension ConfigKey { + /// Convenience initializer for keys with MISTDEMO prefix + /// - Parameters: + /// - base: Base key string (e.g., "cloudkit.container.id") + /// - defaultVal: Required default value + public init(mistDemoPrefixed base: String, default defaultVal: Value) { + self.init(base, envPrefix: "MISTDEMO", default: defaultVal) + } +} + +extension OptionalConfigKey { + /// Convenience initializer for keys with MISTDEMO prefix + /// - Parameter base: Base key string (e.g., "api.token") + public init(mistDemoPrefixed base: String) { + self.init(base, envPrefix: "MISTDEMO") + } +} + +extension ConfigKey where Value == Bool { + /// Convenience initializer for boolean keys with MISTDEMO prefix + /// - Parameters: + /// - base: Base key string (e.g., "debug.enabled") + /// - defaultVal: Default value (defaults to false) + public init(mistDemoPrefixed base: String, default defaultVal: Bool = false) { + self.init(base, envPrefix: "MISTDEMO", default: defaultVal) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift new file mode 100644 index 00000000..6bf6a3ce --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift @@ -0,0 +1,113 @@ +// +// FieldValue+FieldType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +public import MistKit + +extension FieldValue { + /// Initialize FieldValue from a parsed value and field type. + /// + /// This convenience initializer simplifies converting MistDemo's parsed + /// field values into MistKit's FieldValue enum cases. + /// + /// - Parameters: + /// - value: The parsed value (from FieldType.convertValue) + /// - fieldType: The MistDemo FieldType for this value + public init?(value: Any, fieldType: FieldType) { + guard let converted = FieldValue.convert(value: value, fieldType: fieldType) else { + return nil + } + self = converted + } + + // swiftlint:disable:next cyclomatic_complexity + private static func convert(value: Any, fieldType: FieldType) -> FieldValue? { + switch fieldType { + case .string: + guard let stringValue = value as? String else { + return nil + } + return .string(stringValue) + + case .int64: + return convertInt64(value: value) + + case .double: + guard let doubleValue = value as? Double else { + return nil + } + return .double(doubleValue) + + case .timestamp: + guard let dateValue = value as? Date else { + return nil + } + return .date(dateValue) + + case .bytes: + guard let stringValue = value as? String else { + return nil + } + return .bytes(stringValue) + + case .asset: + return convertAsset(value: value) + + case .location, .reference: + // These complex types require specialized handling + // For now, return nil to indicate they're not supported via simple conversion + return nil + } + } + + private static func convertInt64(value: Any) -> FieldValue? { + if let intValue = value as? Int64 { + return .int64(Int(intValue)) + } else if let intValue = value as? Int { + return .int64(intValue) + } + return nil + } + + private static func convertAsset(value: Any) -> FieldValue? { + // Value should be the URL from upload token + guard let urlString = value as? String else { + return nil + } + let asset = Asset( + fileChecksum: nil, + size: nil, + referenceChecksum: nil, + wrappingKey: nil, + receipt: nil, + downloadURL: urlString + ) + return .asset(asset) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift new file mode 100644 index 00000000..9da66b3f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift @@ -0,0 +1,44 @@ +// +// String+Padding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension String { + /// Pad the string on the left to the given width. + internal func leftPadded(toWidth width: Int) -> String { + let pad = max(0, width - count) + return String(repeating: " ", count: pad) + self + } + + /// Pad the string on the right to the given width. + internal func rightPadded(toWidth width: Int) -> String { + let pad = max(0, width - count) + return self + String(repeating: " ", count: pad) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift new file mode 100644 index 00000000..a994e9c8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift @@ -0,0 +1,46 @@ +// +// AssetUploadReceipt+PhaseState.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +extension AssetUploadReceipt: PhaseStateDecodable, PhaseStateEncodable { + internal init(from state: PhaseState) throws { + guard let receipt = state.assetReceipt else { + throw IntegrationTestError.missingPhaseState( + "assetReceipt" + ) + } + self = receipt + } + + internal func encode(to state: inout PhaseState) { + state.assetReceipt = self + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift new file mode 100644 index 00000000..285e6ef1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift @@ -0,0 +1,34 @@ +// +// CleanupPhaseMarker.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Marker protocol identifying the cleanup phase so the runner can skip it +/// when `--skip-cleanup` is set and re-run it on failure. +internal protocol CleanupPhaseMarker {} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift new file mode 100644 index 00000000..f7508863 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift @@ -0,0 +1,49 @@ +// +// CreatedRecordNames.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Wraps the `createdRecordNames` slot of `PhaseState`. +internal struct CreatedRecordNames: PhaseStateDecodable, + PhaseStateEncodable, Sendable +{ + internal let names: [String] + + internal init(_ names: [String]) { + self.names = names + } + + internal init(from state: PhaseState) throws { + self.names = state.createdRecordNames + } + + internal func encode(to state: inout PhaseState) { + state.createdRecordNames = names + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift new file mode 100644 index 00000000..468b3841 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift @@ -0,0 +1,41 @@ +// +// IncrementalSyncInput.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Composite input read by `IncrementalSyncPhase`. +internal struct IncrementalSyncInput: PhaseStateDecodable, Sendable { + internal let syncToken: String? + internal let recordNames: [String] + + internal init(from state: PhaseState) throws { + self.syncToken = state.syncToken + self.recordNames = state.createdRecordNames + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift new file mode 100644 index 00000000..9194b8b6 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift @@ -0,0 +1,64 @@ +// +// IntegrationPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// A single step in an integration test. +/// +/// `Input` and `Output` describe the slices of `PhaseState` the phase reads +/// and writes; the phase itself only carries metadata and run logic. The +/// runner adapts heterogeneous phases via `runErased`, which decodes the +/// input from state, runs the phase, and encodes the output back. +internal protocol IntegrationPhase { + associatedtype Input: PhaseStateDecodable + associatedtype Output: PhaseStateEncodable + + static var title: String { get } + static var emoji: String { get } + static var apiName: String { get } + + func run(input: Input, context: PhaseContext) async throws -> Output + + /// Type-erased entry point used by the runner + /// to drive a `[any IntegrationPhase]`. + func runErased( + context: PhaseContext, state: inout PhaseState + ) async throws +} + +extension IntegrationPhase { + internal func runErased( + context: PhaseContext, state: inout PhaseState + ) async throws { + let input = try Input(from: state) + let output = try await run(input: input, context: context) + output.encode(to: &state) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift new file mode 100644 index 00000000..40e8012c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift @@ -0,0 +1,39 @@ +// +// IntegrationTest.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// An integration test scenario -- typically one per CloudKit database. +internal protocol IntegrationTest { + var name: String { get } + var database: MistKit.Database { get } + + func run(context: PhaseContext) async throws +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestData.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestData.swift new file mode 100644 index 00000000..d5abeb00 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestData.swift @@ -0,0 +1,116 @@ +// +// IntegrationTestData.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Test data generation utilities for integration tests. +internal enum IntegrationTestData { + /// CloudKit record type for integration tests. + internal static let recordType = "MistKitIntegrationTest" + + /// Generate minimal PNG-like binary data for upload testing. + /// + /// Produces data with a valid PNG signature, IHDR, IDAT, and IEND structure, + /// but padding chunks use zeroed CRC32 values (invalid). Not standards-compliant + /// and will be rejected by PNG decoders; suitable only as raw binary test payloads. + /// - Parameter sizeKB: Desired size in kilobytes (default: 10) + /// - Returns: PNG-like binary data + internal static func generateTestImage(sizeKB: Int = 10) -> Data { + // Minimal valid 1x1 pixel PNG + // PNG signature + var data = Data([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ]) + + // IHDR chunk (image header) for 1x1 pixel RGBA image + let ihdrData: [UInt8] = [ + 0x00, 0x00, 0x00, 0x0D, // Chunk length: 13 bytes + 0x49, 0x48, 0x44, 0x52, // Chunk type: "IHDR" + 0x00, 0x00, 0x00, 0x01, // Width: 1 + 0x00, 0x00, 0x00, 0x01, // Height: 1 + 0x08, // Bit depth: 8 + 0x06, // Color type: RGBA + 0x00, // Compression: deflate + 0x00, // Filter: adaptive + 0x00, // Interlace: none + 0x1F, 0x15, 0xC4, 0x89, // CRC32 checksum + ] + data.append(contentsOf: ihdrData) + + // IDAT chunk (image data) - minimal compressed pixel data + let idatData: [UInt8] = [ + 0x00, 0x00, 0x00, 0x0C, // Chunk length: 12 bytes + 0x49, 0x44, 0x41, 0x54, // Chunk type: "IDAT" + 0x08, 0x1D, 0x01, 0x02, 0x00, 0xFD, 0xFF, // Compressed data + 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, + 0xE2, 0x21, 0xBC, 0x33, // CRC32 checksum + ] + data.append(contentsOf: idatData) + + // IEND chunk (image trailer) + let iendData: [UInt8] = [ + 0x00, 0x00, 0x00, 0x00, // Chunk length: 0 + 0x49, 0x45, 0x4E, 0x44, // Chunk type: "IEND" + 0xAE, 0x42, 0x60, 0x82, // CRC32 checksum + ] + data.append(contentsOf: iendData) + + // Pad to requested size with additional IDAT chunks if needed + let targetSize = sizeKB * 1_024 + while data.count < targetSize { + // Add padding IDAT chunks + let remainingBytes = targetSize - data.count + let chunkSize = min(8_192, remainingBytes - 12) // Leave room for chunk overhead + + if chunkSize <= 0 { + break + } + + // Chunk length (4 bytes) + let lengthBytes: [UInt8] = [ + UInt8((chunkSize >> 24) & 0xFF), + UInt8((chunkSize >> 16) & 0xFF), + UInt8((chunkSize >> 8) & 0xFF), + UInt8(chunkSize & 0xFF), + ] + data.append(contentsOf: lengthBytes) + + // Chunk type: "IDAT" + data.append(contentsOf: [0x49, 0x44, 0x41, 0x54]) + + // Padding data + data.append(contentsOf: Array(repeating: UInt8(0x00), count: chunkSize)) + + // Simple CRC32 (not accurate, but sufficient for test data) + data.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) + } + + return data + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift new file mode 100644 index 00000000..75b13aaa --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift @@ -0,0 +1,67 @@ +// +// IntegrationTestError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Errors that can occur during integration testing. +internal enum IntegrationTestError: LocalizedError, Sendable { + case zoneNotFound(String) + case uploadFailed(String) + case recordCreationFailed(String) + case syncTokenMissing + case verificationFailed(String) + case cleanupFailed(String) + case noRecordsCreated + case missingWebAuthToken + case missingPhaseState(String) + + internal var errorDescription: String? { + switch self { + case .zoneNotFound(let zone): + return "Zone not found: \(zone)" + case .uploadFailed(let reason): + return "Asset upload failed: \(reason)" + case .recordCreationFailed(let reason): + return "Record creation failed: \(reason)" + case .syncTokenMissing: + return "Sync token not available from initial fetch" + case .verificationFailed(let reason): + return "Verification failed: \(reason)" + case .cleanupFailed(let reason): + return "Cleanup failed: \(reason)" + case .noRecordsCreated: + return "No records were successfully created" + case .missingWebAuthToken: + return + "Web auth token is required for private database tests. Run 'mistdemo auth-token' first." + case .missingPhaseState(let key): + return "Required phase state '\(key)' is missing — preceding phase did not run" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift new file mode 100644 index 00000000..0a23d6d6 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -0,0 +1,77 @@ +// +// IntegrationTestRunner.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Thin façade that builds a `PhaseContext` from CLI configuration and +/// dispatches to the appropriate `PhasedIntegrationTest` implementation. +internal struct IntegrationTestRunner { + internal let service: CloudKitService + /// Whether the configured `Credentials` carry web-auth material — i.e. + /// whether the single `service` can satisfy user-identity routes + /// (`fetchCaller`, `lookupUsers*`, `discoverUserIdentities`). User-identity + /// phases are scheduled only when this is true. + internal let supportsUserContextPhases: Bool + internal let containerIdentifier: String + internal let database: MistKit.Database + internal let recordCount: Int + internal let assetSizeKB: Int + internal let skipCleanup: Bool + internal let verbose: Bool + /// Optional email forwarded to `PhaseContext.lookupEmail`. + internal let lookupEmail: String? + + /// Run the public-database workflow. + internal func runBasicWorkflow() async throws { + let test = PublicDatabaseTest( + database: database, + includeUserContextPhases: supportsUserContextPhases + ) + try await test.run(context: makeContext()) + } + + /// Run the private-database workflow. + internal func runPrivateWorkflow() async throws { + try await PrivateDatabaseTest().run(context: makeContext()) + } + + private func makeContext() -> PhaseContext { + PhaseContext( + service: service, + containerIdentifier: containerIdentifier, + database: database, + recordCount: recordCount, + assetSizeKB: assetSizeKB, + skipCleanup: skipCleanup, + verbose: verbose, + lookupEmail: lookupEmail + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift new file mode 100644 index 00000000..84ee5a98 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift @@ -0,0 +1,38 @@ +// +// NoState.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Sentinel used as `Input` or `Output` when a phase consumes or produces +/// no `PhaseState`. Stands in for `Void`, which cannot conform to protocols. +internal struct NoState: PhaseStateDecodable, PhaseStateEncodable, Sendable { + internal init() {} + internal init(from state: PhaseState) throws {} + internal func encode(to state: inout PhaseState) {} +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift new file mode 100644 index 00000000..c5a8148c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -0,0 +1,47 @@ +// +// PhaseContext.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Shared dependencies and configuration available to every phase. +internal struct PhaseContext: Sendable { + internal let service: CloudKitService + internal let containerIdentifier: String + internal let database: MistKit.Database + internal let recordCount: Int + internal let assetSizeKB: Int + internal let skipCleanup: Bool + internal let verbose: Bool + /// Optional email address used by `LookupUsersByEmailPhase` to exercise + /// `users/lookup/email` against a known-discoverable iCloud account. When + /// nil, the phase falls back to the caller's own email (often unavailable) + /// and skips otherwise. + internal let lookupEmail: String? +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift new file mode 100644 index 00000000..506c1594 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift @@ -0,0 +1,45 @@ +// +// PhaseState.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Mutable state that flows between phases as the test progresses. +/// +/// Each phase reads the slice it needs by initializing its `Input` type +/// via `PhaseStateDecodable.init(from:)` and writes its results back +/// through `PhaseStateEncodable.encode(to:)`. The runner threads a single +/// `PhaseState` value through the pipeline via +/// `IntegrationPhase.runErased(context:state:)`. +internal struct PhaseState: Sendable { + internal var assetReceipt: AssetUploadReceipt? + internal var createdRecordNames: [String] = [] + internal var syncToken: String? + internal var currentUser: UserInfo? +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift new file mode 100644 index 00000000..cba96cbe --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift @@ -0,0 +1,38 @@ +// +// PhaseStateDecodable.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// A type that can be initialized from `PhaseState`. +/// +/// Modeled after `Decodable`: each phase's `Input` type owns its own +/// rules for reading the slice of `PhaseState` it needs. +internal protocol PhaseStateDecodable: Sendable { + init(from state: PhaseState) throws +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift new file mode 100644 index 00000000..eb97a2e4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift @@ -0,0 +1,38 @@ +// +// PhaseStateEncodable.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// A type that can write itself into `PhaseState`. +/// +/// Modeled after `Encodable`: each phase's `Output` type owns its own +/// rules for writing back into `PhaseState`. +internal protocol PhaseStateEncodable: Sendable { + func encode(to state: inout PhaseState) +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift new file mode 100644 index 00000000..3e483c75 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -0,0 +1,168 @@ +// +// PhasedIntegrationTest.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// An integration test composed of an ordered list of phases. +/// +/// Conformers only need to declare `name`, `database`, and `phases`; the +/// default `run(context:)` implementation drives the array, prints headers, +/// tracks completion, attempts cleanup-on-failure, and prints a summary. +internal protocol PhasedIntegrationTest: IntegrationTest { + var phases: [any IntegrationPhase] { get } +} + +extension PhasedIntegrationTest { + internal func run(context: PhaseContext) async throws { + printHeader(context: context) + + var state = PhaseState() + var completed: [Int] = [] + var skipped: [Int] = [] + + do { + for (index, phase) in phases.enumerated() { + if context.skipCleanup, phase is any CleanupPhaseMarker { + skipped.append(index) + continue + } + try await phase.runErased(context: context, state: &state) + completed.append(index) + } + } catch { + print("\n\u{274C} Error: \(error)") + let cleanupAlreadyRan = phases.enumerated().contains { index, phase in + phase is any CleanupPhaseMarker && completed.contains(index) + } + if !state.createdRecordNames.isEmpty, + !context.skipCleanup, + !cleanupAlreadyRan + { + let count = state.createdRecordNames.count + print( + "\n\u{26A0}\u{FE0F} Attempting cleanup of \(count) test records..." + ) + try? await CleanupPhase().runErased( + context: context, state: &state + ) + } + printSummary( + completed: completed, skipped: skipped, errored: true + ) + throw error + } + + if context.skipCleanup, !state.createdRecordNames.isEmpty { + printSkippedCleanup( + context: context, + recordNames: state.createdRecordNames + ) + } + printSummary( + completed: completed, skipped: skipped, errored: false + ) + } + + // MARK: - Printing + + private func printHeader(context: PhaseContext) { + print("\n" + String(repeating: "=", count: 80)) + print("\u{1F9EA} Integration Test Suite: \(name)") + print(String(repeating: "=", count: 80)) + print("Container: \(context.containerIdentifier)") + let dbLabel = database.pathSegment == "public" ? "public" : "private" + print("Database: \(dbLabel)") + print("Record Count: \(context.recordCount)") + print("Asset Size: \(context.assetSizeKB) KB") + print(String(repeating: "=", count: 80)) + } + + private func printSkippedCleanup( + context: PhaseContext, recordNames: [String] + ) { + print( + "\n\u{26A0}\u{FE0F} Skipping cleanup (--skip-cleanup flag set)" + ) + print(" Test records left in CloudKit:") + for name in recordNames { print(" - \(name)") } + print("\nTo manually cleanup these records:") + print( + " 1. Visit https://icloud.developer.apple.com/dashboard/" + ) + let cid = context.containerIdentifier + print(" 2. Select your container: \(cid)") + let dbName = database.pathSegment == "public" ? "Public" : "Private" + print( + " 3. Navigate to \(dbName) Database \u{2192} Records" + ) + let recType = IntegrationTestData.recordType + print(" 4. Search for record type: \(recType)") + } + + private func printSummary( + completed: [Int], skipped: [Int], errored: Bool + ) { + print("\n" + String(repeating: "=", count: 80)) + let header = + errored + ? "\u{26A0}\u{FE0F} Integration Test Failed" + : "\u{2705} Integration Test Complete!" + print(header) + print(String(repeating: "=", count: 80)) + print("\nPhases:") + + let totalPhases = phases.count + let numberWidth = String(totalPhases).count + + for (index, phase) in phases.enumerated() { + let number = String(index + 1).leftPadded( + toWidth: numberWidth + ) + let phaseType = type(of: phase) + let title = phaseType.title.rightPadded(toWidth: 28) + let label = + "Phase \(number): \(title)(\(phaseType.apiName))" + let marker: String + if completed.contains(index) { + marker = "\u{2705}" + } else if skipped.contains(index) { + marker = "\u{23ED}\u{FE0F} " + } else { + marker = errored ? "\u{274C}" : "\u{23ED}\u{FE0F} " + } + print(" \(marker) \(label)") + } + + print("\n\u{1F4A1} Next steps:") + print(" \u{2022} Run with --verbose for detailed output") + let tip = " \u{2022} Use --skip-cleanup to inspect records" + print("\(tip) in CloudKit Console") + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift new file mode 100644 index 00000000..6016b9cc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift @@ -0,0 +1,82 @@ +// +// CleanupPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct CleanupPhase: IntegrationPhase, CleanupPhaseMarker { + internal typealias Input = CreatedRecordNames + /// Returns an empty `CreatedRecordNames` so the runner clears + /// `state.createdRecordNames` after a successful cleanup, preventing + /// `PhasedIntegrationTest.run`'s on-failure cleanup from re-running. + internal typealias Output = CreatedRecordNames + + internal static let title = "Cleanup test records" + internal static let emoji = "🧹" + internal static let apiName = "deleteRecord" + + internal func run( + input: CreatedRecordNames, + context: PhaseContext + ) async throws -> CreatedRecordNames { + print("\n\(Self.emoji) \(Self.title)") + + var deletedCount = 0 + + // Use forceDelete so no recordChangeTag is required. + let deleteOps = input.names.map { recordName in + RecordOperation( + operationType: .forceDelete, + recordType: IntegrationTestData.recordType, + recordName: recordName + ) + } + + do { + _ = try await context.service.modifyRecords( + deleteOps, + database: context.database + ) + deletedCount = input.names.count + if context.verbose { + for name in input.names { print(" ✅ Deleted: \(name)") } + } + } catch { + if context.verbose { print(" ⚠️ Batch delete failed: \(error)") } + } + + print("✅ Deleted \(deletedCount) test records") + + if deletedCount < input.names.count { + print(" ⚠️ Failed to delete \(input.names.count - deletedCount) records") + } + + return CreatedRecordNames([]) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift new file mode 100644 index 00000000..ef527616 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift @@ -0,0 +1,79 @@ +// +// CreateRecordsPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct CreateRecordsPhase: IntegrationPhase { + internal typealias Input = AssetUploadReceipt + internal typealias Output = CreatedRecordNames + + internal static let title = "Create records with assets" + internal static let emoji = "📝" + internal static let apiName = "createRecord" + + internal func run( + input: AssetUploadReceipt, + context: PhaseContext + ) async throws -> CreatedRecordNames { + print("\n\(Self.emoji) \(Self.title)") + + if context.verbose { + print(" Creating \(context.recordCount) records...") + } + + var createdRecordNames: [String] = [] + + for recordIndex in 1...context.recordCount { + let recordName = "mistkit-test-\(UUID().uuidString.lowercased())" + let record = try await context.service.createRecord( + recordType: IntegrationTestData.recordType, + recordName: recordName, + fields: [ + "title": .string("Test Record \(recordIndex)"), + "index": .int64(recordIndex), + "image": .asset(input.asset), + ], + database: context.database + ) + createdRecordNames.append(record.recordName) + if context.verbose { + print(" ✅ Created: \(record.recordName)") + } + } + + guard !createdRecordNames.isEmpty else { + throw IntegrationTestError.noRecordsCreated + } + + print("✅ Created \(createdRecordNames.count) records") + + return CreatedRecordNames(createdRecordNames) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift new file mode 100644 index 00000000..f6fb5b4e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift @@ -0,0 +1,66 @@ +// +// DiscoverUserIdentitiesPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls POST `/users/discover` to look up specific user identities. +/// +/// Requires public-database web-auth (user-context) credentials. The runner +/// only schedules this phase when the configured `Credentials` carries +/// web-auth material; the service resolves the right token manager per call. +internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { + internal typealias Input = UserInfo + internal typealias Output = NoState + + internal static let title = "Discover user identities" + internal static let emoji = "👥" + internal static let apiName = "discoverUserIdentities" + + internal func run( + input: UserInfo, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let lookupInfos = [UserIdentityLookupInfo(userRecordName: input.userRecordName)] + let identities = try await context.service.discoverUserIdentities( + lookupInfos: lookupInfos + ) + + print("✅ Discovered \(identities.count) user identit\(identities.count == 1 ? "y" : "ies")") + + if context.verbose { + for identity in identities { + if let name = identity.userRecordName { print(" - \(name)") } + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift new file mode 100644 index 00000000..327984e6 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift @@ -0,0 +1,64 @@ +// +// FetchCallerPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls `users/caller`, the user-context endpoint that replaced the deprecated +/// `users/current`. +/// +/// CloudKit only accepts this endpoint against the **public database with +/// web-auth credentials**. The runner only schedules this phase when the +/// configured `Credentials` carries web-auth material; the service resolves +/// the right token manager per call. +internal struct FetchCallerPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = UserInfo + + internal static let title = "Fetch caller (current user)" + internal static let emoji = "👤" + internal static let apiName = "fetchCaller" + + internal func run( + input: NoState, context: PhaseContext + ) async throws -> UserInfo { + print("\n\(Self.emoji) \(Self.title)") + + let userInfo = try await context.service.fetchCaller() + + print("✅ Caller: \(userInfo.userRecordName)") + + if context.verbose { + if let firstName = userInfo.firstName { print(" First name: \(firstName)") } + if let lastName = userInfo.lastName { print(" Last name: \(lastName)") } + } + + return userInfo + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift new file mode 100644 index 00000000..f0c96345 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift @@ -0,0 +1,61 @@ +// +// FetchZoneChangesPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct FetchZoneChangesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Fetch zone changes" + internal static let emoji = "🔄" + internal static let apiName = "fetchZoneChanges" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + do { + let result = try await context.service.fetchZoneChanges(database: context.database) + print("✅ Fetched \(result.zones.count) zone(s)") + if context.verbose { + for zone in result.zones { + print(" - \(zone.zoneName)") + } + if let token = result.syncToken { + print(" Sync token: \(token.prefix(30))...") + } + } + } catch { + print("⚠️ fetchZoneChanges failed (non-fatal): \(error)") + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift new file mode 100644 index 00000000..639f9a93 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift @@ -0,0 +1,57 @@ +// +// FinalVerificationPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct FinalVerificationPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Final zone verification" + internal static let emoji = "🔍" + internal static let apiName = "lookupZones" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let finalZones = try await context.service.lookupZones( + zoneIDs: [.defaultZone], + database: context.database + ) + + guard !finalZones.isEmpty else { + throw IntegrationTestError.verificationFailed("Zone not found after operations") + } + + print("✅ Zone verification complete") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift new file mode 100644 index 00000000..4fc3ae3b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift @@ -0,0 +1,88 @@ +// +// IncrementalSyncPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct IncrementalSyncPhase: IntegrationPhase { + internal typealias Input = IncrementalSyncInput + internal typealias Output = NoState + + internal static let title = "Incremental sync (fetch only changes)" + internal static let emoji = "🔄" + internal static let apiName = "fetchRecordChanges" + + internal func run( + input: IncrementalSyncInput, + context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let token = input.syncToken else { + print( + "⚠️ No sync token available — skipping incremental sync (change tracking requires custom zones)" + ) + return NoState() + } + + if context.verbose { + print(" Using sync token: \(token.prefix(30))...") + } + + do { + let incrementalResult = try await context.service.fetchRecordChanges( + syncToken: token, + database: context.database + ) + + print("✅ Fetched \(incrementalResult.records.count) changed records") + + // New token intentionally not persisted: no later phase chains off it. + if context.verbose, let newToken = incrementalResult.syncToken { + print(" New sync token: \(newToken.prefix(30))...") + } + + let changedRecords = incrementalResult.records.filter { + input.recordNames.contains($0.recordName) + } + print(" Found \(changedRecords.count) of our modified records") + + if context.verbose && !changedRecords.isEmpty { + print(" Modified records:") + for record in changedRecords { + print(" - \(record.recordName)") + } + } + } catch { + print("⚠️ fetchRecordChanges (incremental) failed (non-fatal): \(error)") + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift new file mode 100644 index 00000000..3a8ef274 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift @@ -0,0 +1,76 @@ +// +// InitialSyncPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct InitialSyncPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = SyncTokenSlot + + internal static let title = "Initial sync (fetch all changes)" + internal static let emoji = "🔄" + internal static let apiName = "fetchRecordChanges" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> SyncTokenSlot { + print("\n\(Self.emoji) \(Self.title)") + + do { + let initialResult = try await context.service.fetchRecordChanges( + database: context.database + ) + + print("✅ Fetched \(initialResult.records.count) records") + + if context.verbose { + if let token = initialResult.syncToken { + print(" Sync token: \(token.prefix(30))...") + } + print(" More coming: \(initialResult.moreComing)") + } + + let ourRecords = initialResult.records.filter { input.names.contains($0.recordName) } + print(" Found \(ourRecords.count) of our test records") + + if ourRecords.count != input.names.count && context.verbose { + print(" ⚠️ Expected \(input.names.count), found \(ourRecords.count)") + print(" (Records may not be immediately available)") + } + + return SyncTokenSlot(initialResult.syncToken) + } catch { + print( + "⚠️ fetchRecordChanges failed (non-fatal, change tracking requires custom zones): \(error)" + ) + return SyncTokenSlot(nil) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift new file mode 100644 index 00000000..4f6881a8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift @@ -0,0 +1,60 @@ +// +// ListZonesPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct ListZonesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "List all zones" + internal static let emoji = "📋" + internal static let apiName = "listZones" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zones = try await context.service.listZones(database: context.database) + + guard !zones.isEmpty else { + throw IntegrationTestError.zoneNotFound("(any zone)") + } + + print("✅ Found \(zones.count) zone(s)") + + if context.verbose { + for zone in zones { + print(" - \(zone.zoneName)") + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift new file mode 100644 index 00000000..6f91ac79 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -0,0 +1,66 @@ +// +// LookupRecordsPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct LookupRecordsPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Lookup records by name" + internal static let emoji = "🔍" + internal static let apiName = "lookupRecords" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let lookupNames = Array(input.names.prefix(min(3, input.names.count))) + if context.verbose { + print(" Looking up \(lookupNames.count) of \(input.names.count) record(s) by name") + } + + let records = try await context.service.lookupRecords( + recordNames: lookupNames, + database: context.database + ) + + print("✅ Looked up \(records.count) record(s)") + + if context.verbose { + for record in records { + print(" - \(record.recordName)") + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift new file mode 100644 index 00000000..3dcca32e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift @@ -0,0 +1,87 @@ +// +// LookupUsersByEmailPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls POST `/users/lookup/email`. +/// +/// Prefers the email supplied via `PhaseContext.lookupEmail` +/// (`--lookup-email` / `CLOUDKIT_LOOKUP_EMAIL`) since CloudKit only resolves +/// addresses that belong to iCloud accounts discoverable to the caller. Falls +/// back to the caller's own email when the user-context endpoint exposes it, +/// and skips otherwise — `users/caller` doesn't always return an address. +internal struct LookupUsersByEmailPhase: IntegrationPhase { + internal typealias Input = UserInfo + internal typealias Output = NoState + + internal static let title = "Lookup users by email" + internal static let emoji = "📧" + internal static let apiName = "lookupUsersByEmail" + + internal func run( + input: UserInfo, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let email: String + let source: String + if let configured = context.lookupEmail, !configured.isEmpty { + email = configured + source = "configured --lookup-email" + } else if let callerEmail = input.emailAddress, !callerEmail.isEmpty { + email = callerEmail + source = "caller's own address" + } else { + print( + """ + ⏭️ Skipping — no email available. Set --lookup-email or \ + CLOUDKIT_LOOKUP_EMAIL to exercise this phase. + """ + ) + return NoState() + } + + if context.verbose { + print(" Looking up: \(email) (\(source))") + } + + let identities = try await context.service.lookupUsersByEmail([email]) + + print("✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by email") + + if context.verbose { + for identity in identities { + if let name = identity.userRecordName { print(" - \(name)") } + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift new file mode 100644 index 00000000..3d3465c5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift @@ -0,0 +1,64 @@ +// +// LookupUsersByRecordNamePhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls POST `/users/lookup/id` with the caller's own user record name to +/// exercise the endpoint via a self-lookup. +internal struct LookupUsersByRecordNamePhase: IntegrationPhase { + internal typealias Input = UserInfo + internal typealias Output = NoState + + internal static let title = "Lookup users by record name" + internal static let emoji = "🆔" + internal static let apiName = "lookupUsersByRecordName" + + internal func run( + input: UserInfo, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let identities = try await context.service.lookupUsersByRecordName( + [input.userRecordName] + ) + + print( + "✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by record name" + ) + + if context.verbose { + for identity in identities { + if let name = identity.userRecordName { print(" - \(name)") } + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift new file mode 100644 index 00000000..6ebbd1b9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift @@ -0,0 +1,69 @@ +// +// LookupZonePhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct LookupZonePhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Lookup default zone" + internal static let emoji = "📋" + internal static let apiName = "lookupZones" + + internal func run( + input: NoState, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zones = try await context.service.lookupZones( + zoneIDs: [.defaultZone], + database: context.database + ) + + guard !zones.isEmpty else { + throw IntegrationTestError.zoneNotFound("_defaultZone") + } + + let zone = zones[0] + print("✅ Found zone: \(zone.zoneName)") + + if context.verbose { + if let owner = zone.ownerRecordName { + print(" Owner: \(owner)") + } + if !zone.capabilities.isEmpty { + print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift new file mode 100644 index 00000000..a2b19d1e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift @@ -0,0 +1,74 @@ +// +// ModifyRecordsPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct ModifyRecordsPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Modify some records" + internal static let emoji = "\u{270F}\u{FE0F} " + internal static let apiName = "updateRecord" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let recordsToUpdate = Array(input.names.prefix(min(3, input.names.count))) + + let operations = recordsToUpdate.enumerated().map { offset, recordName in + RecordOperation( + operationType: .forceReplace, + recordType: IntegrationTestData.recordType, + recordName: recordName, + fields: [ + "title": .string("Updated Record \(offset + 1)") + ] + ) + } + + _ = try await context.service.modifyRecords( + operations, + database: context.database + ) + + if context.verbose { + for recordName in recordsToUpdate { + print(" ✅ Updated: \(recordName)") + } + } + + print("✅ Updated \(recordsToUpdate.count) records") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift new file mode 100644 index 00000000..0a91557f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift @@ -0,0 +1,69 @@ +// +// QueryRecordsPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct QueryRecordsPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Query records by type" + internal static let emoji = "🔍" + internal static let apiName = "queryRecords" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + do { + let records = try await context.service.queryRecords( + recordType: IntegrationTestData.recordType + ) + print("✅ Queried \(records.count) record(s) of type '\(IntegrationTestData.recordType)'") + if context.verbose { + let ours = records.filter { input.names.contains($0.recordName) } + print(" Found \(ours.count) of our \(input.names.count) test records") + } + } catch { + // Workaround for Swift 6.3 SIL miscompile (MandatoryAllocBoxToStack) — + // a literal in a destructured-enum `catch` pattern crashes the pass on + // this branch. See SWIFT_COMPILER_BUG.md. Match via `guard case` instead. + guard case CloudKitError.httpErrorWithDetails(statusCode: 404, _, _) = error else { + throw error + } + // Schema propagation in development can lag behind the first write. + // LookupRecordsPhase already verifies the records exist by name. + print("⚠️ queryRecords returned NOT_FOUND — schema may not be indexed yet (non-fatal)") + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift new file mode 100644 index 00000000..999c9045 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift @@ -0,0 +1,69 @@ +// +// UploadAssetPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct UploadAssetPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = AssetUploadReceipt + + internal static let title = "Upload test asset" + internal static let emoji = "📤" + internal static let apiName = "uploadAssets" + + internal func run( + input: NoState, context: PhaseContext + ) async throws -> AssetUploadReceipt { + print("\n\(Self.emoji) \(Self.title)") + + let testData = IntegrationTestData.generateTestImage(sizeKB: context.assetSizeKB) + let sizeInMB = Double(testData.count) / 1_024 / 1_024 + + if context.verbose { + print(" Uploading \(testData.count) bytes (\(String(format: "%.2f", sizeInMB)) MB)...") + } + + let receipt = try await context.service.uploadAssets( + data: testData, + recordType: IntegrationTestData.recordType, + fieldName: "image", + database: context.database + ) + + print("✅ Uploaded asset: \(testData.count) bytes") + + if context.verbose { + print(" Record: \(receipt.recordName)") + print(" Field: \(receipt.fieldName)") + } + + return receipt + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift new file mode 100644 index 00000000..49d4f745 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift @@ -0,0 +1,49 @@ +// +// SyncTokenSlot.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Wraps the `syncToken` slot of `PhaseState`. +internal struct SyncTokenSlot: PhaseStateDecodable, + PhaseStateEncodable, Sendable +{ + internal let value: String? + + internal init(_ value: String?) { + self.value = value + } + + internal init(from state: PhaseState) throws { + self.value = state.syncToken + } + + internal func encode(to state: inout PhaseState) { + state.syncToken = value + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift new file mode 100644 index 00000000..3fbaac55 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -0,0 +1,56 @@ +// +// PrivateDatabaseTest.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct PrivateDatabaseTest: PhasedIntegrationTest { + internal let name = "Private Database" + internal let database: MistKit.Database = .private + + // User-identity phases (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, + // `users/lookup/*`) are intentionally absent: CloudKit Web Services rejects + // these endpoints on the private database with "endpoint not applicable in + // the database type 'privatedb'". They only belong in the public-database + // pipeline; the service resolves web-auth credentials per call when needed. + internal let phases: [any IntegrationPhase] = [ + ListZonesPhase(), + LookupZonePhase(), + FetchZoneChangesPhase(), + UploadAssetPhase(), + CreateRecordsPhase(), + QueryRecordsPhase(), + LookupRecordsPhase(), + InitialSyncPhase(), + ModifyRecordsPhase(), + IncrementalSyncPhase(), + FinalVerificationPhase(), + CleanupPhase(), + ] +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift new file mode 100644 index 00000000..e8cdceca --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -0,0 +1,73 @@ +// +// PublicDatabaseTest.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +internal struct PublicDatabaseTest: PhasedIntegrationTest { + internal let name = "Public Database" + internal let database: MistKit.Database + internal let phases: [any IntegrationPhase] + + /// - Parameters: + /// - database: must be `.public`. Defaults to `.public`. + /// - includeUserContextPhases: when `true`, appends user-identity phases + /// (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, `users/lookup/*`). + /// Those phases need web-auth credentials, which the resolver picks per + /// call from the service's `Credentials`. The runner sets this based on + /// whether web-auth credentials are configured. + internal init( + database: MistKit.Database = .public(.prefers(.serverToServer)), + includeUserContextPhases: Bool = false + ) { + if case .public = database { + } else { + preconditionFailure("PublicDatabaseTest only supports the public database") + } + self.database = database + + var phases: [any IntegrationPhase] = [ + LookupZonePhase(), + UploadAssetPhase(), + CreateRecordsPhase(), + QueryRecordsPhase(), + LookupRecordsPhase(), + ModifyRecordsPhase(), + FinalVerificationPhase(), + CleanupPhase(), + ] + if includeUserContextPhases { + phases.append(FetchCallerPhase()) + phases.append(DiscoverUserIdentitiesPhase()) + phases.append(LookupUsersByEmailPhase()) + phases.append(LookupUsersByRecordNamePhase()) + } + self.phases = phases + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift new file mode 100644 index 00000000..3661f435 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift @@ -0,0 +1,46 @@ +// +// UserInfo+PhaseState.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +extension UserInfo: PhaseStateDecodable, PhaseStateEncodable { + internal init(from state: PhaseState) throws { + guard let user = state.currentUser else { + throw IntegrationTestError.missingPhaseState( + "currentUser" + ) + } + self = user + } + + internal func encode(to state: inout PhaseState) { + state.currentUser = self + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift new file mode 100644 index 00000000..170f5b3c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -0,0 +1,140 @@ +// +// MistDemoRunner.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation + +/// Top-level driver for the `mistdemo` CLI. Registers all available commands, +/// parses arguments, and dispatches to the matching command — the executable +/// target's `@main` is reduced to a single call into `run()`. +public enum MistDemoRunner { + /// Parse arguments and dispatch to the matching command. + @MainActor + public static func run() async throws { + let registry = CommandRegistry.shared + + // Register available commands + #if canImport(Hummingbird) + await registry.register(AuthTokenCommand.self) + await registry.register(WebCommand.self) + #endif + await registry.register(CurrentUserCommand.self) + await registry.register(QueryCommand.self) + await registry.register(CreateCommand.self) + await registry.register(UpdateCommand.self) + await registry.register(DeleteCommand.self) + await registry.register(LookupCommand.self) + await registry.register(ModifyCommand.self) + await registry.register(UploadAssetCommand.self) + await registry.register(DemoInFilterCommand.self) + await registry.register(LookupZonesCommand.self) + await registry.register(FetchChangesCommand.self) + await registry.register(TestPublicCommand.self) + await registry.register(TestPrivateCommand.self) + await registry.register(DemoErrorsCommand.self) + + // Parse command line arguments + let parser = CommandLineParser() + + // Check for help + if parser.isHelpRequested() { + if let commandName = parser.parseCommandName() { + await printCommandHelp(commandName, registry: registry) + } else { + await printGeneralHelp(registry: registry) + } + return + } + + // Check if a command was specified + if let commandName = parser.parseCommandName() { + try await executeCommand(commandName, registry: registry) + } else { + await printMissingCommandError(registry: registry) + } + } + + /// Execute a specific command + private static func executeCommand(_ commandName: String, registry: CommandRegistry) async throws + { + do { + let command = try await registry.createCommand(named: commandName) + try await command.execute() + } catch let error as CommandRegistryError { + print("❌ \(error.localizedDescription)") + let availableCommands = await registry.availableCommands + print("Available commands: \(availableCommands.joined(separator: ", "))") + print("Run 'mistdemo help' for usage information.") + throw error + } + } + + /// Print general help + @MainActor + private static func printGeneralHelp(registry: CommandRegistry) async { + print("MistDemo - CloudKit Web Services Command Line Tool") + print("") + print("USAGE:") + print(" mistdemo [options]") + print("") + print("COMMANDS:") + let availableCommands = await registry.availableCommands + for commandName in availableCommands { + if let metadata = await registry.metadata(for: commandName) { + let paddedName = commandName.padding(toLength: 12, withPad: " ", startingAt: 0) + print(" \(paddedName) \(metadata.abstract)") + } + } + print("") + print("OPTIONS:") + print(" --help, -h Show help information") + print("") + print("Run 'mistdemo --help' for command-specific help.") + } + + /// Print command-specific help + @MainActor + private static func printCommandHelp(_ commandName: String, registry: CommandRegistry) async { + if let metadata = await registry.metadata(for: commandName) { + print(metadata.helpText) + } else { + print("Unknown command: \(commandName)") + await printGeneralHelp(registry: registry) + } + } + + /// Print error when no command is specified + @MainActor + private static func printMissingCommandError(registry: CommandRegistry) async { + print("❌ No command specified.") + print("💡 Use the command-based interface:") + print("") + await printGeneralHelp(registry: registry) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift b/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift new file mode 100644 index 00000000..507d9b18 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift @@ -0,0 +1,46 @@ +// +// AuthRequest.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Request model for authentication callback from CloudKit Web Services. +/// +/// This model is used by the AuthTokenCommand's Hummingbird server to decode +/// incoming authentication data from CloudKit's OAuth flow. When a user +/// successfully authenticates with CloudKit, the redirect callback sends +/// this data to the local server. +/// +/// - Note: Used in AuthTokenCommand.swift line 84 for decoding Hummingbird route requests +internal struct AuthRequest: Decodable { + /// The session token provided by CloudKit after successful authentication. + internal let sessionToken: String + + /// The user's CloudKit record name identifier. + internal let userRecordName: String +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoKit/Models/Note.swift new file mode 100644 index 00000000..9b67b48b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Models/Note.swift @@ -0,0 +1,104 @@ +// +// Note.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Note record, mirroring the `Note` type defined in `schema.ckdb`: +/// +/// RECORD TYPE Note ( +/// "title" STRING QUERYABLE SORTABLE SEARCHABLE, +/// "index" INT64 QUERYABLE SORTABLE, +/// "image" ASSET +/// ); +/// +/// Created / modified timestamps come from CloudKit's system metadata +/// (`CKRecord.creationDate` / `.modificationDate`), so there's no need +/// for custom `createdAt` / `modified` schema fields. +public struct Note: Identifiable, Hashable { + /// Known field name constants for `Note` records. + public enum Fields { + /// Title field name. + public static let title = "title" + /// Index field name. + public static let index = "index" + /// Image asset field name. + public static let image = "image" + } + + /// CloudKit record type identifier. + public static let recordType = "Note" + + /// Record name / identifier. + public let id: String + /// Note title. + public let title: String? + /// Sort-order index. + public let index: Int64? + /// URL of the attached image asset, if any. + public let imageAssetURL: URL? + + /// Last-modification timestamp from CloudKit system metadata. + public let modificationDate: Date? + /// Creation timestamp from CloudKit system metadata. + public let creationDate: Date? + /// CloudKit change tag for optimistic concurrency. + public let recordChangeTag: String? + /// `recordName` of the user who created the record. + public let creatorUserRecordName: String? + + /// Creates a `Note` from its field values plus optional CloudKit metadata. + public init( + id: String, + title: String? = nil, + index: Int64? = nil, + imageAssetURL: URL? = nil, + modificationDate: Date? = nil, + creationDate: Date? = nil, + recordChangeTag: String? = nil, + creatorUserRecordName: String? = nil + ) { + self.id = id + self.title = title + self.index = index + self.imageAssetURL = imageAssetURL + self.modificationDate = modificationDate + self.creationDate = creationDate + self.recordChangeTag = recordChangeTag + self.creatorUserRecordName = creatorUserRecordName + } + + // Identity-based equality: two Notes with the same recordID are equal + // regardless of field state. Lets SwiftUI selection bindings track a + // record across edits without losing focus when fields change. + + /// Identity-based equality on `id`. + public static func == (lhs: Note, rhs: Note) -> Bool { lhs.id == rhs.id } + /// Identity-based hashing on `id`. + public func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/ZoneRow.swift b/Examples/MistDemo/Sources/MistDemoKit/Models/ZoneRow.swift new file mode 100644 index 00000000..fc43ed65 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Models/ZoneRow.swift @@ -0,0 +1,47 @@ +// +// ZoneRow.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Display-friendly snapshot of a CKRecordZone for the SwiftUI list. +public struct ZoneRow: Identifiable, Hashable { + /// Stable identifier composed of zone + owner name. + public let id: String + /// CloudKit zone name. + public let zoneName: String + /// CloudKit zone owner record name. + public let ownerName: String + + /// Creates a row from its component identifiers. + public init(id: String, zoneName: String, ownerName: String) { + self.id = id + self.zoneName = zoneName + self.ownerName = ownerName + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift new file mode 100644 index 00000000..5ce1de49 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift @@ -0,0 +1,59 @@ +// +// CSVEscaper.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// CSV escaper conforming to RFC 4180 +public struct CSVEscaper: OutputEscaper { + /// Creates a new instance. + public init() {} + + /// Escapes the string for CSV output. + public func escape(_ string: String) -> String { + // Check if escaping is needed + // Use unicodeScalars to avoid Swift treating \r\n as a single grapheme cluster + let needsEscaping = string.unicodeScalars.contains { scalar in + switch scalar { + case ",", "\"", "\n", "\r", "\t": + return true + default: + return false + } + } + + // If no special characters, return as-is + guard needsEscaping else { + return string + } + + // Escape quotes by doubling them and wrap in quotes + let escaped = string.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift new file mode 100644 index 00000000..f84338ec --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift @@ -0,0 +1,51 @@ +// +// JSONEscaper.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// JSON escaper (usually handled by JSONEncoder, but useful for manual JSON building) +public struct JSONEscaper: OutputEscaper { + /// Creates a new instance. + public init() {} + + /// Escapes the string for JSON output. + public func escape(_ string: String) -> String { + let escaped = + string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + .replacingOccurrences(of: "\u{000C}", with: "\\f") + .replacingOccurrences(of: "\u{0008}", with: "\\b") + + return escaped + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift new file mode 100644 index 00000000..4c114ec9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift @@ -0,0 +1,49 @@ +// +// OutputEscaperFactory.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Factory for creating output escapers based on output format +public enum OutputEscaperFactory { + /// Create an appropriate escaper for the given output format + /// - Parameter format: The output format + /// - Returns: An escaper configured for the specified format + public static func escaper(for format: OutputFormat) -> any OutputEscaper { + switch format { + case .csv: + return CSVEscaper() + case .yaml: + return YAMLEscaper() + case .json: + return JSONEscaper() + case .table: + return TableEscaper() + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift new file mode 100644 index 00000000..2e354f66 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift @@ -0,0 +1,47 @@ +// +// TableEscaper.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Table escaper for plain text table output +public struct TableEscaper: OutputEscaper { + /// Creates a new instance. + public init() {} + + /// Escapes the string for table output. + public func escape(_ string: String) -> String { + // For table output, replace newlines with spaces and trim + // This ensures single-line values in table cells + string + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\t", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift new file mode 100644 index 00000000..73420ae8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift @@ -0,0 +1,162 @@ +// +// YAMLEscaper.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// YAML escaper for proper string formatting +public struct YAMLEscaper: OutputEscaper { + /// Creates a new instance. + public init() {} + + /// Escapes the string for YAML output. + public func escape(_ string: String) -> String { + // Check if the string needs escaping + guard needsEscaping(string) else { + return string + } + + // For multi-line strings, use literal block scalar + if string.contains("\n") { + return blockScalar(string) + } + + // For single-line strings with special characters, use double quotes + let escaped = + string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\t", with: "\\t") + .replacingOccurrences(of: "\r", with: "\\r") + + return "\"\(escaped)\"" + } + + // MARK: - Private Helpers + + /// Check if a string needs YAML escaping + private func needsEscaping(_ string: String) -> Bool { + // Empty strings need quotes + if string.isEmpty { + return true + } + + if needsEscapingByBoundary(string) { + return true + } + + if needsEscapingByContent(string) { + return true + } + + return false + } + + /// Check boundary characters and reserved patterns. + private func needsEscapingByBoundary(_ string: String) -> Bool { + // Characters that are special only as the first char of a plain scalar + let firstCharSpecials: Set = [ + ":", "#", "@", "`", "|", ">", "'", "\"", + "[", "]", "{", "}", ",", "&", "*", "!", + "%", "\\", "?", "-", "<", "=", "~", + ] + + // Check first character for special cases + if let first = string.first { + if firstCharSpecials.contains(first) || first.isWhitespace { + return true + } + } + + // Check last character for whitespace + if let last = string.last, last.isWhitespace { + return true + } + + // Check for special patterns + let specialPatterns = [ + "yes", "no", "true", "false", "on", "off", + "null", "~", "YES", "NO", "TRUE", "FALSE", + "ON", "OFF", "NULL", "Yes", "No", "True", + "False", "On", "Off", "Null", + ] + + return specialPatterns.contains(string) + } + + /// Check interior characters and numeric patterns. + private func needsEscapingByContent(_ string: String) -> Bool { + // Characters that are special anywhere in a plain scalar + let anyCharSpecials: Set = [ + ":", "#", "`", "|", ">", "\"", + "[", "]", "{", "}", ",", "&", "*", "!", + "%", "\\", + ] + + // Check if it looks like a number + if Double(string) != nil || Int(string) != nil { + return true + } + + // Check for special characters in the string + for char in string + where anyCharSpecials.contains(char) + || char == "\n" || char == "\r" || char == "\t" + { + return true + } + + return false + } + + /// Create a YAML block scalar for multi-line strings + private func blockScalar(_ string: String) -> String { + // Use literal block scalar (|) for multi-line strings + // This preserves line breaks and doesn't require escaping + let lines = string.split(separator: "\n", omittingEmptySubsequences: false) + + // Use literal scalar to preserve formatting + var result = "|\n" + + // Indent each line with 2 spaces (or 4 spaces for better readability) + for line in lines { + if line.isEmpty { + result += "\n" + } else { + result += " \(line)\n" + } + } + + // Remove trailing newline if original didn't have one + if !string.hasSuffix("\n") && result.hasSuffix("\n") { + result.removeLast() + } + + return result + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift new file mode 100644 index 00000000..519b900f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift @@ -0,0 +1,100 @@ +// +// CSVFormatter.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Formatter for CSV output +public struct CSVFormatter: OutputFormatter { + // MARK: Lifecycle + + /// Creates a new instance. + public init() {} + + // MARK: Public + + /// Formats the value as CSV. + public func format(_ value: T) throws -> String { + let escaper = CSVEscaper() + + // For CSV format, we need to handle specific types + if let recordInfo = value as? RecordInfo { + return formatRecord(recordInfo, escaper: escaper) + } else if let userInfo = value as? UserInfo { + return formatUser(userInfo, escaper: escaper) + } else { + // Fall back to JSON for unknown types + let jsonFormatter = JSONFormatter(pretty: false) + return try jsonFormatter.format(value) + } + } + + // MARK: Private + + private func formatRecord(_ record: RecordInfo, escaper: CSVEscaper) -> String { + var output = "" + + // Header + output += "Field,Value\n" + + // Basic fields + output += "recordName,\(escaper.escape(record.recordName))\n" + output += "recordType,\(escaper.escape(record.recordType))\n" + + // Custom fields + for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { + let valueString = FieldValueFormatter.displayString(fieldValue) + output += "\(escaper.escape(fieldName)),\(escaper.escape(valueString))\n" + } + + return output + } + + private func formatUser(_ user: UserInfo, escaper: CSVEscaper) -> String { + var output = "" + + // Header + output += "Field,Value\n" + + // User fields + output += "userRecordName,\(escaper.escape(user.userRecordName))\n" + + if let firstName = user.firstName { + output += "firstName,\(escaper.escape(firstName))\n" + } + if let lastName = user.lastName { + output += "lastName,\(escaper.escape(lastName))\n" + } + if let emailAddress = user.emailAddress { + output += "emailAddress,\(escaper.escape(emailAddress))\n" + } + + return output + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift new file mode 100644 index 00000000..185b3d3a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift @@ -0,0 +1,53 @@ +// +// OutputFormatterFactory.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Factory for creating output formatters based on output format +public enum OutputFormatterFactory { + /// Create an appropriate formatter for the given output format + /// - Parameters: + /// - format: The output format + /// - pretty: Whether to use pretty printing (applies to JSON) + /// - Returns: A formatter configured for the specified format + public static func formatter(for format: OutputFormat, pretty: Bool = false) + -> any OutputFormatter + { + switch format { + case .json: + return JSONFormatter(pretty: pretty) + case .table: + return TableFormatter() + case .csv: + return CSVFormatter() + case .yaml: + return YAMLFormatter() + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift new file mode 100644 index 00000000..19a4efe4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift @@ -0,0 +1,95 @@ +// +// TableFormatter.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Formatter for table output +public struct TableFormatter: OutputFormatter { + // MARK: Lifecycle + + /// Creates a new instance. + public init() {} + + // MARK: Public + + /// Formats the value as a plain text table. + public func format(_ value: T) throws -> String { + // For table format, we need to handle specific types + // since table formatting is inherently structure-dependent + if let recordInfo = value as? RecordInfo { + return try formatRecord(recordInfo) + } else if let userInfo = value as? UserInfo { + return try formatUser(userInfo) + } else { + // Fall back to JSON for unknown types + let jsonFormatter = JSONFormatter(pretty: true) + return try jsonFormatter.format(value) + } + } + + // MARK: Private + + private func formatRecord(_ record: RecordInfo) throws -> String { + let escaper = TableEscaper() + var output = "" + + output += "Record Name: \(escaper.escape(record.recordName))\n" + output += "Record Type: \(escaper.escape(record.recordType))\n" + + if !record.fields.isEmpty { + output += "Fields:\n" + for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { + let valueString = escaper.escape(FieldValueFormatter.displayString(fieldValue)) + output += " \(fieldName): \(valueString)\n" + } + } + + return output + } + + private func formatUser(_ user: UserInfo) throws -> String { + let escaper = TableEscaper() + var output = "" + + output += "User Record Name: \(escaper.escape(user.userRecordName))\n" + + if let firstName = user.firstName { + output += "First Name: \(escaper.escape(firstName))\n" + } + if let lastName = user.lastName { + output += "Last Name: \(escaper.escape(lastName))\n" + } + if let emailAddress = user.emailAddress { + output += "Email: \(escaper.escape(emailAddress))\n" + } + + return output + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift new file mode 100644 index 00000000..b7669305 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift @@ -0,0 +1,94 @@ +// +// YAMLFormatter.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Formatter for YAML output +public struct YAMLFormatter: OutputFormatter { + // MARK: Lifecycle + + /// Creates a new instance. + public init() {} + + // MARK: Public + + /// Formats the value as YAML. + public func format(_ value: T) throws -> String { + let escaper = YAMLEscaper() + + // For YAML format, we need to handle specific types + if let recordInfo = value as? RecordInfo { + return formatRecord(recordInfo, escaper: escaper) + } else if let userInfo = value as? UserInfo { + return formatUser(userInfo, escaper: escaper) + } else { + // Fall back to JSON for unknown types + let jsonFormatter = JSONFormatter(pretty: true) + return try jsonFormatter.format(value) + } + } + + // MARK: Private + + private func formatRecord(_ record: RecordInfo, escaper: YAMLEscaper) -> String { + var output = "" + + output += "recordName: \(escaper.escape(record.recordName))\n" + output += "recordType: \(escaper.escape(record.recordType))\n" + + if !record.fields.isEmpty { + output += "fields:\n" + for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { + let valueString = FieldValueFormatter.displayString(fieldValue) + output += " \(escaper.escape(fieldName)): \(escaper.escape(valueString))\n" + } + } + + return output + } + + private func formatUser(_ user: UserInfo, escaper: YAMLEscaper) -> String { + var output = "" + + output += "userRecordName: \(escaper.escape(user.userRecordName))\n" + + if let firstName = user.firstName { + output += "firstName: \(escaper.escape(firstName))\n" + } + if let lastName = user.lastName { + output += "lastName: \(escaper.escape(lastName))\n" + } + if let emailAddress = user.emailAddress { + output += "emailAddress: \(escaper.escape(emailAddress))\n" + } + + return output + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/JSONFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift similarity index 96% rename from Examples/MistDemo/Sources/MistDemo/Output/JSONFormatter.swift rename to Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift index 284ed302..34df776c 100644 --- a/Examples/MistDemo/Sources/MistDemo/Output/JSONFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift @@ -31,17 +31,19 @@ import Foundation /// Formatter for JSON output public struct JSONFormatter: OutputFormatter { + /// Whether to use pretty printing + public let pretty: Bool + // MARK: Lifecycle + /// Creates a new instance. public init(pretty: Bool = false) { self.pretty = pretty } // MARK: Public - /// Whether to use pretty printing - public let pretty: Bool - + /// Formats the value as JSON. public func format(_ value: T) throws -> String { let encoder = JSONEncoder() if pretty { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift new file mode 100644 index 00000000..d719c329 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift @@ -0,0 +1,47 @@ +// +// OutputFormat.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Supported output formats +public enum OutputFormat: String, Sendable, CaseIterable { + case json + case table + case csv + case yaml + + // MARK: Public + + /// Create the appropriate formatter for this format + /// - Parameter pretty: Whether to use pretty printing (applies to JSON) + /// - Returns: A formatter configured for this format + public func createFormatter(pretty: Bool = false) -> any OutputFormatter { + OutputFormatterFactory.formatter(for: self, pretty: pretty) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift new file mode 100644 index 00000000..985051ee --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift @@ -0,0 +1,36 @@ +// +// OutputFormatter.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Protocol for formatting output in different formats +public protocol OutputFormatter: Sendable { + /// Format an encodable value to a string + func format(_ value: T) throws -> String +} diff --git a/Examples/MistDemo/Sources/MistDemo/Output/Protocols/OutputEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift similarity index 86% rename from Examples/MistDemo/Sources/MistDemo/Output/Protocols/OutputEscaper.swift rename to Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift index 6fdb0060..d70cfcb2 100644 --- a/Examples/MistDemo/Sources/MistDemo/Output/Protocols/OutputEscaper.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift @@ -31,8 +31,8 @@ import Foundation /// Protocol for escaping strings for specific output formats public protocol OutputEscaper: Sendable { - /// Escape a string for the specific output format - /// - Parameter string: The string to escape - /// - Returns: The escaped string suitable for the output format - func escape(_ string: String) -> String + /// Escape a string for the specific output format + /// - Parameter string: The string to escape + /// - Returns: The escaped string suitable for the output format + func escape(_ string: String) -> String } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift new file mode 100644 index 00000000..36a7c4af --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift @@ -0,0 +1,95 @@ +// +// OutputFormatting+Implementations.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +// MARK: - Format-specific implementations + +extension OutputFormatting { + /// Output results in JSON format + internal func outputJSON(_ results: [T]) async throws { + let jsonData: Data + if results.count == 1 { + jsonData = try JSONEncoder().encode(results[0]) + } else { + jsonData = try JSONEncoder().encode(results) + } + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw OutputFormattingError.encodingFailure("Failed to encode JSON") + } + + print(jsonString) + } + + /// Output results in table format + internal func outputTable(_ results: [T]) async throws { + if results.isEmpty { + print(MistDemoConstants.Messages.noRecordsFound) + return + } + + // Table output is type-specific, so we need to handle known types + if let records = results as? [RecordInfo] { + try await outputRecordTable(records) + } else if let userInfo = results.first as? UserInfo, results.count == 1 { + try await outputUserTable(userInfo) + } else { + // Fall back to JSON for unknown types + try await outputJSON(results) + } + } + + /// Output results in CSV format + internal func outputCSV(_ results: [T]) async throws { + // CSV output is type-specific, so we need to handle known types + if let records = results as? [RecordInfo] { + try await outputRecordCSV(records) + } else if let userInfo = results.first as? UserInfo, results.count == 1 { + try await outputUserCSV([userInfo]) + } else { + // Fall back to JSON for unknown types + try await outputJSON(results) + } + } + + /// Output results in YAML format + internal func outputYAML(_ results: [T]) async throws { + // YAML output is type-specific, so we need to handle known types + if let records = results as? [RecordInfo] { + try await outputRecordYAML(records) + } else if let userInfo = results.first as? UserInfo, results.count == 1 { + try await outputUserYAML(userInfo) + } else { + // Fall back to JSON for unknown types + try await outputJSON(results) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift new file mode 100644 index 00000000..555cfd26 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift @@ -0,0 +1,219 @@ +// +// OutputFormatting+Records.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +// MARK: - RecordInfo Output Formatting + +extension OutputFormatting { + // Output RecordInfo results in table format. + // swiftlint:disable:next cyclomatic_complexity + internal func outputRecordTable(_ records: [RecordInfo], fields: [String]? = nil) async throws { + if records.isEmpty { + print(MistDemoConstants.Messages.noRecordsFound) + return + } + + if records.count == 1 { + // Single record - detailed view + let record = records[0] + print(MistDemoConstants.Messages.recordCreated) + print("├─ Name: \(record.recordName)") + print("├─ Type: \(record.recordType)") + if let changeTag = record.recordChangeTag { + print("├─ Change Tag: \(changeTag)") + } + print("└─ Fields:") + + let fieldsToShow = filterFields(record.fields, fields: fields) + for (fieldName, fieldValue) in fieldsToShow { + let formattedValue = FieldValueFormatter.formatFieldValue(fieldValue) + print(" ├─ \(fieldName): \(formattedValue)") + } + } else { + // Multiple records - list view + print("Found \(records.count) record(s):") + print(String(repeating: "=", count: 50)) + + for (index, record) in records.enumerated() { + print("\n[\(index + 1)] Record: \(record.recordName)") + print(" Type: \(record.recordType)") + if let changeTag = record.recordChangeTag { + print(" Change Tag: \(changeTag)") + } + print(" Fields:") + + let fieldsToShow = filterFields(record.fields, fields: fields) + for (fieldName, fieldValue) in fieldsToShow { + let formattedValue = FieldValueFormatter.formatFieldValue(fieldValue) + print(" \(fieldName): \(formattedValue)") + } + } + } + } + + // Output RecordInfo results in CSV format. + // swiftlint:disable:next cyclomatic_complexity + internal func outputRecordCSV(_ records: [RecordInfo], fields: [String]? = nil) async throws { + // Collect all unique field names (filtered if requested) + let allFieldNames = Set( + records.flatMap { record in + record.fields.keys.filter { fieldName in + shouldIncludeField(fieldName, fields: fields) + } + } + ) + + let sortedFieldNames = + [ + MistDemoConstants.FieldNames.recordName, + MistDemoConstants.FieldNames.recordType, + MistDemoConstants.FieldNames.recordChangeTag, + ].filter { shouldIncludeField($0, fields: fields) } + allFieldNames.sorted() + + // Print header + print(sortedFieldNames.joined(separator: ",")) + + // Print records + let csvEscaper = CSVEscaper() + for record in records { + var values: [String] = [] + for fieldName in sortedFieldNames { + switch fieldName { + case MistDemoConstants.FieldNames.recordName: + values.append(csvEscaper.escape(record.recordName)) + case MistDemoConstants.FieldNames.recordType: + values.append(csvEscaper.escape(record.recordType)) + case MistDemoConstants.FieldNames.recordChangeTag: + values.append(csvEscaper.escape(record.recordChangeTag ?? "")) + default: + if let fieldValue = record.fields[fieldName] { + let formatted = FieldValueFormatter.formatFieldValue(fieldValue) + values.append(csvEscaper.escape(formatted)) + } else { + values.append("") + } + } + } + print(values.joined(separator: ",")) + } + } + + /// Output RecordInfo results in YAML format. + internal func outputRecordYAML( + _ records: [RecordInfo], fields: [String]? = nil + ) async throws { + let yamlEscaper = YAMLEscaper() + let recordNameKey = MistDemoConstants.FieldNames.recordName + let recordTypeKey = MistDemoConstants.FieldNames.recordType + let recordChangeTagKey = MistDemoConstants.FieldNames.recordChangeTag + if records.count == 1 { + let record = records[0] + print("record:") + let name = yamlEscaper.escape(record.recordName) + print(" \(recordNameKey): \(name)") + let rtype = yamlEscaper.escape(record.recordType) + print(" \(recordTypeKey): \(rtype)") + if let changeTag = record.recordChangeTag { + let tag = yamlEscaper.escape(changeTag) + print(" \(recordChangeTagKey): \(tag)") + } + print(" fields:") + printYAMLFields( + record: record, + fields: fields, + yamlEscaper: yamlEscaper, + indent: " " + ) + } else { + print("records:") + for record in records { + let name = yamlEscaper.escape(record.recordName) + print(" - \(recordNameKey): \(name)") + let rtype = yamlEscaper.escape(record.recordType) + print(" \(recordTypeKey): \(rtype)") + if let changeTag = record.recordChangeTag { + let tag = yamlEscaper.escape(changeTag) + print(" \(recordChangeTagKey): \(tag)") + } + print(" fields:") + printYAMLFields( + record: record, + fields: fields, + yamlEscaper: yamlEscaper, + indent: " " + ) + } + } + } + + private func printYAMLFields( + record: RecordInfo, + fields: [String]?, + yamlEscaper: YAMLEscaper, + indent: String + ) { + let fieldsToShow = filterFields( + record.fields, fields: fields + ) + for (fieldName, fieldValue) in fieldsToShow { + let formatted = FieldValueFormatter.formatFieldValue( + fieldValue + ) + print("\(indent)\(fieldName): \(yamlEscaper.escape(formatted))") + } + } + + // MARK: - Helper Methods + + /// Filter fields based on the fields parameter + private func filterFields(_ fields: [String: FieldValue], fields fieldsFilter: [String]?) + -> [String: FieldValue] + { + guard let fieldsFilter = fieldsFilter, !fieldsFilter.isEmpty else { + return fields + } + + return fields.filter { fieldName, _ in + shouldIncludeField(fieldName, fields: fieldsFilter) + } + } + + /// Check if a field should be included based on field filter + private func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { + guard let fields = fields, !fields.isEmpty else { + return true // Include all fields if no filter specified + } + + return fields.contains { requestedField in + fieldName.lowercased() == requestedField.lowercased() + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift new file mode 100644 index 00000000..3cbe5ea7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift @@ -0,0 +1,125 @@ +// +// OutputFormatting+Users.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +// MARK: - UserInfo Output Formatting + +extension OutputFormatting { + /// Output UserInfo result in table format + internal func outputUserTable(_ userInfo: UserInfo, fields: [String]? = nil) async throws { + print("User Information:") + print("├─ User Record Name: \(userInfo.userRecordName)") + + if shouldIncludeUserField("firstName", fields: fields), let firstName = userInfo.firstName { + print("├─ First Name: \(firstName)") + } + + if shouldIncludeUserField("lastName", fields: fields), let lastName = userInfo.lastName { + print("├─ Last Name: \(lastName)") + } + + if shouldIncludeUserField("emailAddress", fields: fields), let email = userInfo.emailAddress { + print("└─ Email: \(email)") + } else { + // Adjust the last character if email is not shown + print("") // Just end the tree properly + } + } + + // Output UserInfo results in CSV format. + // swiftlint:disable:next cyclomatic_complexity + internal func outputUserCSV(_ users: [UserInfo], fields: [String]? = nil) async throws { + // Build header based on available fields + var headers: [String] = ["userRecordName"] + + if shouldIncludeUserField("firstName", fields: fields) { + headers.append("firstName") + } + if shouldIncludeUserField("lastName", fields: fields) { + headers.append("lastName") + } + if shouldIncludeUserField("emailAddress", fields: fields) { + headers.append("emailAddress") + } + + print(headers.joined(separator: ",")) + + // Output user data + let csvEscaper = CSVEscaper() + for user in users { + var values: [String] = [csvEscaper.escape(user.userRecordName)] + + if shouldIncludeUserField("firstName", fields: fields) { + values.append(csvEscaper.escape(user.firstName ?? "")) + } + if shouldIncludeUserField("lastName", fields: fields) { + values.append(csvEscaper.escape(user.lastName ?? "")) + } + if shouldIncludeUserField("emailAddress", fields: fields) { + values.append(csvEscaper.escape(user.emailAddress ?? "")) + } + + print(values.joined(separator: ",")) + } + } + + /// Output UserInfo result in YAML format + internal func outputUserYAML(_ userInfo: UserInfo, fields: [String]? = nil) async throws { + let yamlEscaper = YAMLEscaper() + print("user:") + print(" userRecordName: \(yamlEscaper.escape(userInfo.userRecordName))") + + if shouldIncludeUserField("firstName", fields: fields), let firstName = userInfo.firstName { + print(" firstName: \(yamlEscaper.escape(firstName))") + } + + if shouldIncludeUserField("lastName", fields: fields), let lastName = userInfo.lastName { + print(" lastName: \(yamlEscaper.escape(lastName))") + } + + if shouldIncludeUserField("emailAddress", fields: fields), let email = userInfo.emailAddress { + print(" emailAddress: \(yamlEscaper.escape(email))") + } + } + + // MARK: - Helper Methods + + /// Check if a user field should be included based on field filter + private func shouldIncludeUserField(_ fieldName: String, fields: [String]?) -> Bool { + guard let fields = fields, !fields.isEmpty else { + return true // Include all fields if no filter specified + } + + return fields.contains { requestedField in + fieldName.lowercased() == requestedField.lowercased() + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift new file mode 100644 index 00000000..0640a32b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift @@ -0,0 +1,61 @@ +// +// OutputFormatting.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Protocol for formatting command output in different formats +public protocol OutputFormatting { + /// Output a single result in the specified format + func outputResult(_ result: T, format: OutputFormat) async throws + + /// Output multiple results in the specified format + func outputResults(_ results: [T], format: OutputFormat) async throws +} + +extension OutputFormatting { + /// Default implementation for outputting a single result + public func outputResult(_ result: T, format: OutputFormat) async throws { + try await outputResults([result], format: format) + } + + /// Default implementation for outputting multiple results + public func outputResults(_ results: [T], format: OutputFormat) async throws { + switch format { + case .json: + try await outputJSON(results) + case .table: + try await outputTable(results) + case .csv: + try await outputCSV(results) + case .yaml: + try await outputYAML(results) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html new file mode 100644 index 00000000..2bf13fb0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -0,0 +1,1055 @@ + + + + + + MistKit Web Demo + + + + +

+
+

MistKit Web Demo

+

+ Authenticate with your Apple ID, then exercise the same CloudKit + operations through MistKit (server) or CloudKit JS (browser) and + compare the wire-level behavior. +

+ +

Backend

+
+ + +
+
+ MistKit mode routes browser → Hummingbird → CloudKit Web Services. + CloudKit JS mode routes browser → CloudKit Web Services directly. + Both share the same Apple ID session token, hit the same container, + and exercise the same REST surface — only the SDK shape differs. +
+ +

Database

+
+ + +
+
+ Private uses the captured Apple ID web-auth token; Public uses + server-to-server signing on the MistKit side and the API token on + the CloudKit JS side. Browsers can't perform S2S signing, so + "MistKit + Public" is unique to the server path. +
+ +

Auth

+
+
+ +
+
+
+ +
+

Notes MistKit Private

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + +
TitleIndex + Created + + Modified +
No notes loaded — click Refresh.
+
+
+
+ +
+

+ New note + +

+ + + + +
+ + + +
+
+
+ Last raw response +
(none yet)
+
+
+
+
+
+ + + + diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift new file mode 100644 index 00000000..869e0a07 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift @@ -0,0 +1,51 @@ +// +// LoopbackOnlyMiddleware.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Hummingbird + + /// Rejects requests whose `:authority` is not a loopback host with + /// `403 Forbidden`. Scoped to the `/api` router group so the local-only + /// surface (config, auth capture, CRUD) can't be reached from a + /// non-loopback origin while the index page itself stays unguarded. + internal struct LoopbackOnlyMiddleware: + RouterMiddleware + { + internal func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + guard LoopbackAuthority.isLoopback(request.head.authority ?? "") else { + return Response(status: .forbidden) + } + return try await next(request, context) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift new file mode 100644 index 00000000..b04b8b99 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift @@ -0,0 +1,66 @@ +// +// WebAuthTokenStore.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Thread-safe holder for the captured `ckWebAuthToken`. +/// +/// The web-demo's `/api/authenticate` route writes here when the browser +/// completes the CloudKit auth flow; the CRUD routes read here on each +/// request to authorize themselves against the captured session. +/// +/// `tokenUpdates` yields each captured token so one-shot consumers (e.g. +/// the auth-token command) can await the first emission and shut down. +internal actor WebAuthTokenStore { + private var token: String? + private let updatesContinuation: AsyncStream.Continuation + nonisolated internal let tokenUpdates: AsyncStream + + internal var currentToken: String? { + self.token + } + + internal init(token: String? = nil) { + self.token = token + let (stream, continuation) = AsyncStream.makeStream() + self.tokenUpdates = stream + self.updatesContinuation = continuation + } + + internal func update(_ token: String) { + self.token = token + updatesContinuation.yield(token) + } + + internal func clear() { + self.token = nil + } + + deinit { + updatesContinuation.finish() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift new file mode 100644 index 00000000..ff8039fd --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -0,0 +1,135 @@ +// +// WebBackend.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Narrow abstraction over the MistKit `CloudKitService` methods the web +/// demo's CRUD routes call. Lets the routes be tested without a live +/// CloudKit container — tests supply a mock conformer. +/// +/// The production implementation is `CloudKitService` itself via +/// extension; the web demo builds a new service per request using the +/// captured `ckWebAuthToken` (and, when configured, server-to-server +/// signing material for the public database). +internal protocol WebBackend: Sendable { + func webQuery( + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database + ) async throws -> [RecordInfo] + + func webCreate( + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo + + func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String?, + database: MistKit.Database + ) async throws -> RecordInfo + + func webDelete( + recordType: String, + recordName: String, + recordChangeTag: String?, + database: MistKit.Database + ) async throws +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension CloudKitService: WebBackend { + internal func webQuery( + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database + ) async throws -> [RecordInfo] { + let querySorts = sortBy?.map { sort in + QuerySort.sort(sort.field, ascending: sort.ascending) + } + let result = try await queryRecords( + recordType: recordType, + filters: nil, + sortBy: querySorts, + limit: limit, + desiredKeys: nil, + continuationMarker: nil, + database: database + ) + return result.records + } + + internal func webCreate( + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo { + try await createRecord( + recordType: recordType, + fields: fields, + database: database + ) + } + + internal func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String?, + database: MistKit.Database + ) async throws -> RecordInfo { + try await updateRecord( + recordType: recordType, + recordName: recordName, + fields: fields, + recordChangeTag: recordChangeTag, + database: database + ) + } + + internal func webDelete( + recordType: String, + recordName: String, + recordChangeTag: String?, + database: MistKit.Database + ) async throws { + try await deleteRecord( + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag, + database: database + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift new file mode 100644 index 00000000..05aa7374 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift @@ -0,0 +1,78 @@ +// +// WebBackendFactory.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import MistKit + + /// Factory that returns a `WebBackend` configured with the captured + /// web-auth token. Injected into `WebServer` so tests can supply a + /// mock without going through MistKit. + /// + /// When server-to-server credentials are present, the produced service + /// holds both auth flavors and `CloudKitService` picks the right one + /// per operation based on the request's `database`. + internal struct WebBackendFactory: Sendable { + internal let make: @Sendable (_ webAuthToken: String) throws -> any WebBackend + + internal init( + make: + @escaping @Sendable (_ webAuthToken: String) throws -> any WebBackend + ) { + self.make = make + } + + /// Production factory: builds a `CloudKitService` for the captured + /// web-auth token paired with the command's API token. If + /// `serverToServer` is non-nil, the same service can also satisfy + /// public-database routes via S2S signing. + internal static func live( + apiToken: String, + containerIdentifier: String, + environment: MistKit.Environment, + serverToServer: ServerToServerCredentials? = nil + ) -> WebBackendFactory { + WebBackendFactory { webAuthToken in + let apiAuth = APICredentials( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + let credentials = try Credentials( + serverToServer: serverToServer, + apiAuth: apiAuth + ) + return CloudKitService( + containerIdentifier: containerIdentifier, + credentials: credentials, + environment: environment + ) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift new file mode 100644 index 00000000..080456a3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift @@ -0,0 +1,57 @@ +// +// WebIndexHTML.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + + /// Loader for the web command's interactive page served by `WebServer`. + /// + /// The HTML+JS lives in `Resources/index.html` and is read from + /// `Bundle.module` on first access. The mode toggle in this page lets + /// users compare MistKit (server-side) and CloudKit JS (browser-side) + /// against the same CloudKit container; the CloudKit JS side is wired + /// in by #329. + internal enum WebIndexHTML { + internal static let content: String = loadContent() + + private static func loadContent() -> String { + guard + let url = Bundle.module.url( + forResource: "index", withExtension: "html" + ), + let html = try? String(contentsOf: url, encoding: .utf8) + else { + preconditionFailure( + "Resources/index.html missing from MistDemoKit bundle" + ) + } + return html + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift new file mode 100644 index 00000000..d492da04 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift @@ -0,0 +1,44 @@ +// +// WebJSON.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Shared JSON encoder for the web demo's CRUD response bodies. +/// +/// Uses `.millisecondsSince1970` so timestamps in `RecordInfo.created` / +/// `RecordInfo.modified` arrive in the browser as epoch-millis numbers +/// that JS can pass to `new Date(ms)` — the same shape CloudKit Web +/// Services returns to CloudKit JS. +internal enum WebJSON { + internal static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift new file mode 100644 index 00000000..27b03945 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -0,0 +1,198 @@ +// +// WebRequests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Request payloads for the web command's CRUD endpoints. +/// +/// `fields` decodes directly into MistKit's `FieldValue`, which has a custom +/// Codable that accepts raw JSON primitives (string → `.string`, integer → +/// `.int64`, floating-point → `.double`) along with the complex CloudKit +/// shapes (location, reference, asset, list). So the browser can send the +/// natural `{"title":"Hi","index":5}` shape without a custom request type. +internal enum WebRequests { + /// One sort descriptor: a field name plus a direction. Field names follow + /// CloudKit Web Services / CloudKit JS naming — including the implicit + /// system fields `___createTime` and `___modTime`, which must be marked + /// SORTABLE in the schema. + internal struct QuerySortField: Decodable, Sendable { + /// CloudKit Web Services field name. Note: CloudKit JS's + /// `performQuery({ sortBy })` uses `fieldName` for the same concept — + /// the browser-side code maps this property to `fieldName` when issuing + /// CloudKit-JS-mode queries (see `queryNotes` in `index.html`). + internal let field: String + internal let ascending: Bool + } + + /// `POST /api/records/query` + internal struct Query: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case limit + case sortBy + case database + } + + internal let recordType: String + internal let limit: Int? + internal let sortBy: [QuerySortField]? + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.limit = try container.decodeIfPresent(Int.self, forKey: .limit) + self.sortBy = try container.decodeIfPresent( + [QuerySortField].self, forKey: .sortBy + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/records/create` + internal struct Create: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case fields + case database + } + + internal let recordType: String + internal let fields: [String: FieldValue] + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.fields = try container.decode( + [String: FieldValue].self, forKey: .fields + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/records/update` + /// + /// `recordChangeTag` carries the optimistic-locking token CloudKit returns + /// on every record. The browser already holds it from the last query, so + /// it forwards directly to MistKit without a server-side fetch round-trip. + internal struct Update: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case recordName + case fields + case recordChangeTag + case database + } + + internal let recordType: String + internal let recordName: String + internal let fields: [String: FieldValue] + internal let recordChangeTag: String? + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.recordName = try container.decode(String.self, forKey: .recordName) + self.fields = try container.decode( + [String: FieldValue].self, forKey: .fields + ) + self.recordChangeTag = try container.decodeIfPresent( + String.self, forKey: .recordChangeTag + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/records/delete` + /// + /// `recordChangeTag` is required by CloudKit Web Services to delete an + /// existing record. Omitting it produces `BadRequestException: missing + /// required field 'recordChangeTag'`. + internal struct Delete: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case recordName + case recordChangeTag + case database + } + + internal let recordType: String + internal let recordName: String + internal let recordChangeTag: String? + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.recordName = try container.decode(String.self, forKey: .recordName) + self.recordChangeTag = try container.decodeIfPresent( + String.self, forKey: .recordChangeTag + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// CloudKit database targeted by a request. Defaults to `.private` when + /// the field is omitted so legacy clients (pre-database-picker) keep + /// working. + internal static let defaultDatabase: MistKit.Database = .private + + /// Decode `database` (string raw-value) from a keyed container. Falls back + /// to `defaultDatabase` when the key is absent and throws when present but + /// unrecognized so a typo surfaces as a `400` rather than a silent default. + fileprivate static func decodeDatabase( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> MistKit.Database { + guard let raw = try container.decodeIfPresent(String.self, forKey: key) + else { + return defaultDatabase + } + guard let database = MistDemoConfig.parseDatabase(raw) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: + "Unrecognized database '\(raw)' — expected one of: public, private, shared" + ) + } + return database + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift new file mode 100644 index 00000000..1fadb4f9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -0,0 +1,51 @@ +// +// WebResponse.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Response payloads for the web command's CRUD endpoints. +internal enum WebResponse { + /// Body returned by record-shaped routes (query / create / update). + internal struct Records: Encodable { + internal let records: [RecordInfo] + } + + /// Body returned by `delete` (no record payload). + internal struct Delete: Encodable { + internal let recordName: String + internal let deleted: Bool + } + + /// Body returned for any handled CloudKit/MistKit error so the UI can + /// surface the message without parsing transport-level failures. + internal struct Error: Encodable { + internal let message: String + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift new file mode 100644 index 00000000..998763cc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -0,0 +1,146 @@ +// +// WebServer+CRUD.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + extension WebServer { + internal func addQueryEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/query") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Query.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let records = try await backend.webQuery( + recordType: body.recordType, + limit: body.limit, + sortBy: body.sortBy, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Records(records: records) + ) + } + } + } + + internal func addCreateEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/create") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Create.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let record = try await backend.webCreate( + recordType: body.recordType, + fields: body.fields, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Records(records: [record]) + ) + } + } + } + + internal func addUpdateEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/update") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Update.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let record = try await backend.webUpdate( + recordType: body.recordType, + recordName: body.recordName, + fields: body.fields, + recordChangeTag: body.recordChangeTag, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Records(records: [record]) + ) + } + } + } + + internal func addDeleteEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/delete") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Delete.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + try await backend.webDelete( + recordType: body.recordType, + recordName: body.recordName, + recordChangeTag: body.recordChangeTag, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Delete( + recordName: body.recordName, deleted: true + ) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift new file mode 100644 index 00000000..0e7b0da4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -0,0 +1,177 @@ +// +// WebServer.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import Logging + internal import MistKit + + /// Routing surface for the long-running `mistdemo web` command. + /// + /// Owns the index page, the CloudKit JS config endpoint, the auth-capture + /// endpoint, and the CRUD record endpoints. Mode-toggle between MistKit + /// (server-side, this server's routes) and CloudKit JS (browser-side, + /// served from Apple's CDN) lives in the HTML; this server only + /// implements the MistKit side. + internal struct WebServer { + /// JSON payload returned by `GET /api/config`, consumed by the + /// browser-side script to configure both CloudKit JS and the mode- + /// toggle's MistKit handlers. + /// + /// `publicDatabaseAvailable` lets the browser know whether the server + /// holds server-to-server credentials and can therefore route MistKit + /// requests against `.public`. CloudKit JS can always target the public + /// database from the browser (it only needs the API token), so the flag + /// gates only the MistKit + public profile. + internal struct CloudKitClientConfig: Encodable { + internal let apiToken: String + internal let containerIdentifier: String + internal let environment: String + internal let publicDatabaseAvailable: Bool + } + + internal let apiToken: String + internal let containerIdentifier: String + internal let environment: MistKit.Environment + internal let publicDatabaseAvailable: Bool + internal let tokenStore: WebAuthTokenStore + internal let backendFactory: WebBackendFactory + /// When `true`, `POST /api/authenticate` returns `205 Reset Content` to + /// signal the browser that the server is about to shut down (auth-token + /// flow). When `false`, returns `204 No Content` (web flow stays up). + internal let terminatesAfterAuth: Bool + + internal static func jsonResponse( + status: HTTPResponse.Status, bytes: Data + ) -> Response { + Response( + status: status, + headers: [.contentType: "application/json"], + body: ResponseBody { writer in + try await writer.write(ByteBuffer(bytes: bytes)) + try await writer.finish(nil) + } + ) + } + + /// Run a route operation that produces a success JSON body. Any thrown + /// error becomes a `500` response with a JSON error payload so the UI + /// can surface the failure without parsing transport-level errors. + internal static func runOperation( + _ operation: @Sendable () async throws -> Data + ) async throws -> Response { + do { + let bytes = try await operation() + return jsonResponse(status: .ok, bytes: bytes) + } catch { + let errorBody = try JSONEncoder().encode( + WebResponse.Error( + message: error.localizedDescription + ) + ) + return jsonResponse( + status: .internalServerError, bytes: errorBody + ) + } + } + + /// Build the router for this server. + internal func makeRouter() throws -> Router { + let router = Router(context: BasicRequestContext.self) + router.middlewares.add(LogRequestsMiddleware(.info)) + + addIndexEndpoint(router: router) + + let api = router.group("api") + .add(middleware: LoopbackOnlyMiddleware()) + let configData = try JSONEncoder().encode( + CloudKitClientConfig( + apiToken: apiToken, + containerIdentifier: containerIdentifier, + environment: environment.rawValue, + publicDatabaseAvailable: publicDatabaseAvailable + ) + ) + addConfigEndpoint(api: api, configData: configData) + addAuthEndpoint(api: api) + addQueryEndpoint(api: api) + addCreateEndpoint(api: api) + addUpdateEndpoint(api: api) + addDeleteEndpoint(api: api) + + return router + } + + private func addIndexEndpoint( + router: Router + ) { + let indexBytes = ByteBuffer(string: WebIndexHTML.content) + let indexResponseBuilder: @Sendable () -> Response = { + Response( + status: .ok, + headers: [.contentType: "text/html; charset=utf-8"], + body: ResponseBody { writer in + try await writer.write(indexBytes) + try await writer.finish(nil) + } + ) + } + router.get("/") { _, _ -> Response in indexResponseBuilder() } + router.get("/index.html") { _, _ -> Response in + indexResponseBuilder() + } + } + + private func addConfigEndpoint( + api: RouterGroup, + configData: Data + ) { + api.get("config") { _, _ -> Response in + Self.jsonResponse(status: .ok, bytes: configData) + } + } + + private func addAuthEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let successStatus: HTTPResponse.Status = + terminatesAfterAuth ? .resetContent : .noContent + api.post("authenticate") { request, context -> Response in + let authRequest = try await request.decode( + as: AuthRequest.self, context: context + ) + await tokenStore.update(authRequest.sessionToken) + return Response(status: successStatus) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Types/AnyCodable.swift b/Examples/MistDemo/Sources/MistDemoKit/Types/AnyCodable.swift new file mode 100644 index 00000000..9698b522 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Types/AnyCodable.swift @@ -0,0 +1,83 @@ +// +// AnyCodable.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Helper for decoding arbitrary JSON values. +internal struct AnyCodable: Codable { + internal let value: Any + + internal init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if container.decodeNil() { + value = NSNull() + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unable to decode value" + ) + ) + } + } + + internal func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case let stringValue as String: + try container.encode(stringValue) + case let intValue as Int: + try container.encode(intValue) + case let doubleValue as Double: + try container.encode(doubleValue) + case let boolValue as Bool: + try container.encode(boolValue) + case is NSNull: + try container.encodeNil() + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Unable to encode value of type \(type(of: value))" + ) + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Types/DynamicKey.swift b/Examples/MistDemo/Sources/MistDemoKit/Types/DynamicKey.swift similarity index 75% rename from Examples/MistDemo/Sources/MistDemo/Types/DynamicKey.swift rename to Examples/MistDemo/Sources/MistDemoKit/Types/DynamicKey.swift index e0cb99f2..23f4e3b9 100644 --- a/Examples/MistDemo/Sources/MistDemo/Types/DynamicKey.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Types/DynamicKey.swift @@ -27,20 +27,20 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +import Foundation -/// Dynamic coding key for handling arbitrary JSON object keys -struct DynamicKey: CodingKey { - var stringValue: String - var intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } -} \ No newline at end of file +/// Dynamic coding key for handling arbitrary JSON object keys. +internal struct DynamicKey: CodingKey { + internal var stringValue: String + internal var intValue: Int? + + internal init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + internal init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Types/FieldInputValue.swift b/Examples/MistDemo/Sources/MistDemoKit/Types/FieldInputValue.swift new file mode 100644 index 00000000..9d6125f0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Types/FieldInputValue.swift @@ -0,0 +1,55 @@ +// +// FieldInputValue.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Enum representing different types of field input values +public enum FieldInputValue: Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case asset(String) // Asset URL from upload token + + /// Convert to FieldType and string value for Field creation. + internal func toFieldComponents() throws -> (FieldType, String) { + switch self { + case .string(let value): + return (.string, value) + case .int(let value): + return (.int64, String(value)) + case .double(let value): + return (.double, String(value)) + case .bool(let value): + return (.string, value ? "true" : "false") + case .asset(let url): + return (.asset, url) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Types/FieldsInput.swift b/Examples/MistDemo/Sources/MistDemoKit/Types/FieldsInput.swift new file mode 100644 index 00000000..276042c4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Types/FieldsInput.swift @@ -0,0 +1,99 @@ +// +// FieldsInput.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Type-safe representation of field input from JSON +public struct FieldsInput: Codable, Sendable { + private let storage: [String: FieldInputValue] + + /// Decode fields from a keyed JSON container. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: DynamicKey.self) + var fields: [String: FieldInputValue] = [:] + + for key in container.allKeys { + if let stringValue = try? container.decode(String.self, forKey: key) { + fields[key.stringValue] = .string(stringValue) + } else if let intValue = try? container.decode(Int.self, forKey: key) { + fields[key.stringValue] = .int(intValue) + } else if let doubleValue = try? container.decode(Double.self, forKey: key) { + fields[key.stringValue] = .double(doubleValue) + } else if let boolValue = try? container.decode(Bool.self, forKey: key) { + fields[key.stringValue] = .bool(boolValue) + } else { + // Try to decode as a generic JSON value and convert to string + let jsonValue = try container.decode(AnyCodable.self, forKey: key) + fields[key.stringValue] = .string(String(describing: jsonValue.value)) + } + } + + self.storage = fields + } + + /// Encode fields to a keyed JSON container. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + + for (key, value) in storage { + guard let dynamicKey = DynamicKey(stringValue: key) else { + continue + } + try encodeValue(value, forKey: dynamicKey, in: &container) + } + } + + /// Encode a single field value into the given container. + private func encodeValue( + _ value: FieldInputValue, + forKey key: DynamicKey, + in container: inout KeyedEncodingContainer + ) throws { + switch value { + case .string(let stringValue): + try container.encode(stringValue, forKey: key) + case .int(let intValue): + try container.encode(intValue, forKey: key) + case .double(let doubleValue): + try container.encode(doubleValue, forKey: key) + case .bool(let boolValue): + try container.encode(boolValue, forKey: key) + case .asset(let url): + try container.encode(url, forKey: key) + } + } + + /// Convert to Field array for CloudKit processing + public func toFields() throws -> [Field] { + try storage.map { name, value in + let (fieldType, stringValue) = try value.toFieldComponents() + return Field(name: name, type: fieldType, value: stringValue) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift new file mode 100644 index 00000000..a4845b16 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift @@ -0,0 +1,133 @@ +// +// AsyncHelpers.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +import UnixSignals + +/// Timeout error for async operations +public enum AsyncTimeoutError: Error, LocalizedError { + case timeout(String) + case cancelled(String) + + /// A localized description of the timeout error. + public var errorDescription: String? { + switch self { + case .timeout(let message): + return "Operation timed out: \(message)" + case .cancelled(let message): + return "Operation cancelled: \(message)" + } + } +} + +/// Execute an async operation with a timeout +public func withTimeout( + seconds: Double, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + let deadline = ContinuousClock.now.advanced(by: .milliseconds(Int(seconds * 1_000))) + while ContinuousClock.now < deadline { + try Task.checkCancellation() + try? await Task.sleep(for: .milliseconds(10)) + } + throw AsyncTimeoutError.timeout("Operation timed out after \(seconds) seconds") + } + + guard let result = try await group.next() else { + throw AsyncTimeoutError.timeout("Timeout task failed") + } + + group.cancelAll() + return result + } +} + +/// Execute an async operation with signal handling (Ctrl+C, SIGTERM) +public func withSignalHandling( + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + #if os(Linux) || os(macOS) + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + let signals = await UnixSignalsSequence(trapping: [.sigint, .sigterm]) + for try await signal in signals { + print("\n⚠️ Received signal: \(signal)") + throw AsyncTimeoutError.cancelled("Operation cancelled by signal") + } + throw AsyncTimeoutError.cancelled("Signal handler completed unexpectedly") + } + + guard let result = try await group.next() else { + throw AsyncTimeoutError.cancelled("Task group completed without result") + } + + group.cancelAll() + return result + } + #else + return try await operation() + #endif +} + +/// Execute an async operation with signal handling and an optional timeout. +/// +/// Pass `seconds: nil` to run until a signal (Ctrl+C / SIGTERM) arrives — +/// used by long-running commands like `mistdemo web`. Pass a positive value +/// to cap the wait — used by one-shot commands like `mistdemo auth-token`. +public func withTimeoutAndSignals( + seconds: Double?, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withSignalHandling { + if let seconds { + return try await withTimeout(seconds: seconds, operation: operation) + } + return try await operation() + } +} + +/// Format a timeout duration for user display +public func formatTimeout(_ seconds: Double) -> String { + if seconds < 60 { + return "\(Int(seconds)) seconds" + } else { + let minutes = Int(seconds / 60) + return "\(minutes) minute\(minutes == 1 ? "" : "s")" + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationError.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationError.swift similarity index 89% rename from Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationError.swift rename to Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationError.swift index 65fa33ce..fcd047b5 100644 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationError.swift @@ -29,8 +29,8 @@ import Foundation -/// Errors that can occur during authentication setup -enum AuthenticationError: LocalizedError, Sendable { +/// Errors that can occur during authentication setup. +internal enum AuthenticationError: LocalizedError, Sendable { case serverToServerRequiresPublicDatabase case failedToReadPrivateKeyFile(path: String, errorDescription: String) case missingPrivateKey @@ -43,11 +43,11 @@ enum AuthenticationError: LocalizedError, Sendable { // MARK: Internal - var errorDescription: String? { + internal var errorDescription: String? { switch self { case .serverToServerRequiresPublicDatabase: "Server-to-server authentication only supports public database access" - case let .failedToReadPrivateKeyFile(path, errorDescription): + case .failedToReadPrivateKeyFile(let path, let errorDescription): "Failed to read private key file at \(path): \(errorDescription)" case .missingPrivateKey: "Server-to-server authentication requires a private key (use --private-key or --private-key-file)" @@ -56,7 +56,8 @@ enum AuthenticationError: LocalizedError, Sendable { case .invalidServerToServerCredentials: "Server-to-server credentials validation failed. Check your key ID and private key." case .privateRequiresWebAuth: - "Private database access requires web authentication token. Use 'mistdemo auth' to sign in with Apple ID or provide --web-auth-token" + "Private database access requires web authentication token." + + " Use 'mistdemo auth' to sign in with Apple ID or provide --web-auth-token" case .invalidWebAuthCredentials: "Web authentication credentials validation failed. Token may be expired." case .invalidAPIToken: diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift new file mode 100644 index 00000000..13d49e28 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift @@ -0,0 +1,165 @@ +// +// AuthenticationHelper+SetupHelpers.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +extension AuthenticationHelper { + internal static func setupServerToServer( + keyID: String, + privateKey: String?, + privateKeyFile: String?, + databaseOverride: MistKit.Database? + ) async throws -> AuthenticationResult { + let database: MistKit.Database = .public(.prefers(.serverToServer)) + + if databaseOverride == .private { + throw AuthenticationError.serverToServerRequiresPublicDatabase + } + + let manager = try await createServerToServerManager( + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile + ) + + let method = + "\u{1F510} Server-to-server authentication" + + " (public database only)" + return AuthenticationResult( + tokenManager: manager, + database: database, + authMethod: method + ) + } + + internal static func setupWebAuth( + apiToken: String, + webAuthToken: String, + databaseOverride: MistKit.Database? + ) async throws -> AuthenticationResult { + let database: MistKit.Database = databaseOverride ?? .private + + let manager = try await createWebAuthManager( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + + let method = + "\u{1F310} Web authentication (\(database) database)" + return AuthenticationResult( + tokenManager: manager, + database: database, + authMethod: method + ) + } + + internal static func setupAPIOnly( + apiToken: String, + databaseOverride: MistKit.Database? + ) async throws -> AuthenticationResult { + let database: MistKit.Database = .public(.prefers(.serverToServer)) + + if databaseOverride == .private { + throw AuthenticationError.privateRequiresWebAuth + } + + let manager = APITokenManager(apiToken: apiToken) + + let isValid = try await manager.validateCredentials() + guard isValid else { + throw AuthenticationError.invalidAPIToken + } + + let method = + "\u{1F511} API-only authentication (public database only)" + return AuthenticationResult( + tokenManager: manager, + database: database, + authMethod: method + ) + } + + internal static func createServerToServerManager( + keyID: String, + privateKey: String?, + privateKeyFile: String? + ) async throws -> any TokenManager { + let privateKeyPEM: String + if let keyFile = privateKeyFile { + do { + privateKeyPEM = try String( + contentsOfFile: keyFile, encoding: .utf8 + ) + } catch { + throw AuthenticationError.failedToReadPrivateKeyFile( + path: keyFile, + errorDescription: error.localizedDescription + ) + } + } else if let key = privateKey { + privateKeyPEM = key + } else { + throw AuthenticationError.missingPrivateKey + } + + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + throw AuthenticationError.serverToServerNotSupported + } + + let manager = try ServerToServerAuthManager( + keyID: keyID, + pemString: privateKeyPEM + ) + + let isValid = try await manager.validateCredentials() + guard isValid else { + throw AuthenticationError.invalidServerToServerCredentials + } + + return manager + } + + internal static func createWebAuthManager( + apiToken: String, + webAuthToken: String + ) async throws -> any TokenManager { + let manager = WebAuthTokenManager( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + + let isValid = try await manager.validateCredentials() + guard isValid else { + throw AuthenticationError.invalidWebAuthCredentials + } + + return manager + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper.swift new file mode 100644 index 00000000..0850ffb8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper.swift @@ -0,0 +1,100 @@ +// +// AuthenticationHelper.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Helper utilities for managing CloudKit authentication. +internal enum AuthenticationHelper { + /// A function that maps an environment-variable name to its value. + internal typealias EnvironmentReader = + @Sendable (String) -> String? + + /// Default reader backed by `ProcessInfo`. + internal static let processEnvironmentReader: EnvironmentReader = { + ProcessInfo.processInfo.environment[$0] + } + + // MARK: - Public API + + /// Creates appropriate TokenManager and determines database. + internal static func setupAuthentication( + apiToken: String, + webAuthToken: String?, + keyID: String?, + privateKey: String?, + privateKeyFile: String?, + databaseOverride: MistKit.Database? = nil + ) async throws -> AuthenticationResult { + if let keyID { + return try await setupServerToServer( + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile, + databaseOverride: databaseOverride + ) + } + + if let webAuthToken, !webAuthToken.isEmpty { + return try await setupWebAuth( + apiToken: apiToken, + webAuthToken: webAuthToken, + databaseOverride: databaseOverride + ) + } + + return try await setupAPIOnly( + apiToken: apiToken, + databaseOverride: databaseOverride + ) + } + + /// Resolves API token from option or environment variable. + internal static func resolveAPIToken( + _ apiToken: String, + environment: EnvironmentReader = processEnvironmentReader + ) -> String { + apiToken.isEmpty + ? environment("CLOUDKIT_API_TOKEN") ?? "" + : apiToken + } + + /// Resolves web auth token from option or environment variable. + internal static func resolveWebAuthToken( + _ webAuthToken: String, + environment: EnvironmentReader = processEnvironmentReader + ) -> String? { + let envKey = MistDemoConstants.EnvironmentVars.cloudKitWebAuthToken + let token = + webAuthToken.isEmpty + ? environment(envKey) ?? "" + : webAuthToken + return token.isEmpty ? nil : token + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationResult.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationResult.swift similarity index 86% rename from Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationResult.swift rename to Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationResult.swift index a74273ad..30e1c689 100644 --- a/Examples/MistDemo/Sources/MistDemo/Utilities/AuthenticationResult.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationResult.swift @@ -30,9 +30,10 @@ import Foundation import MistKit -/// Result of authentication setup including token manager and selected database -struct AuthenticationResult { - let tokenManager: any TokenManager - let database: MistKit.Database - let authMethod: String // Description for logging +/// Result of authentication setup including token manager and selected database. +internal struct AuthenticationResult { + internal let tokenManager: any TokenManager + internal let database: MistKit.Database + /// Description for logging. + internal let authMethod: String } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/BrowserOpener.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/BrowserOpener.swift new file mode 100644 index 00000000..804aa561 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/BrowserOpener.swift @@ -0,0 +1,52 @@ +// +// BrowserOpener.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +#if canImport(AppKit) + import AppKit +#endif + +/// Utility for opening URLs in the default browser. +internal enum BrowserOpener { + /// Open a URL in the default browser. + /// - Parameter url: The URL string to open. + internal static func openBrowser(url: String) { + #if canImport(AppKit) + if let url = URL(string: url) { + NSWorkspace.shared.open(url) + } + #elseif os(Linux) + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["xdg-open", url] + try? process.run() + #endif + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift new file mode 100644 index 00000000..54099475 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift @@ -0,0 +1,97 @@ +// +// FieldValueFormatter.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Utility for formatting FieldValue objects for display. +internal enum FieldValueFormatter { + // Extract the raw display string from a FieldValue. + // swiftlint:disable:next cyclomatic_complexity + internal static func displayString( + _ value: FieldValue + ) -> String { + switch value { + case .string(let string): + return string + case .int64(let int): + return "\(int)" + case .double(let double): + return "\(double)" + case .bytes(let bytes): + return bytes + case .date(let date): + return formatDate(date) + case .location(let location): + return "(\(location.latitude), \(location.longitude))" + case .reference(let reference): + return reference.recordName + case .asset(let asset): + return asset.downloadURL ?? "no URL" + case .list(let values): + let items = values.map { displayString($0) } + return "[\(items.joined(separator: ", "))]" + } + } + + // Format a single FieldValue for display. + // swiftlint:disable:next cyclomatic_complexity + internal static func formatFieldValue( + _ value: FieldValue + ) -> String { + switch value { + case .string(let string): + return "\"\(string)\"" + case .int64(let int): + return "\(int)" + case .double(let double): + return "\(double)" + case .bytes(let bytes): + return "bytes(\(bytes.count) chars, base64: \(bytes))" + case .date(let date): + return "date(\(formatDate(date)))" + case .location(let location): + return "location(\(location.latitude), \(location.longitude))" + case .reference(let reference): + return "reference(\(reference.recordName))" + case .asset(let asset): + return "asset(\(asset.downloadURL ?? "no URL"))" + case .list(let values): + let items = values.map { formatFieldValue($0) } + return "[\(items.joined(separator: ", "))]" + } + } + + private static func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift new file mode 100644 index 00000000..87575122 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift @@ -0,0 +1,84 @@ +// +// LoopbackAuthority.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Helper for validating that an HTTP `:authority` value identifies a +/// loopback host. +/// +/// Used by the auth-token server to reject requests that target the +/// loopback callback from non-loopback hosts (e.g. forwarded ports or +/// remote browsers proxying into the process). +internal enum LoopbackAuthority { + /// Hosts treated as loopback. Bracketed form is used for IPv6 because + /// that is the canonical authority shape. + internal static let allowed: Set = [ + "localhost", + "127.0.0.1", + "[::1]", + ] + + /// Returns `true` when the authority's host (port stripped) matches one + /// of the recognized loopback hosts. + /// + /// - Parameter authority: An HTTP `:authority` value such as + /// `"127.0.0.1:8080"`, `"localhost"`, or `"[::1]:8080"`. + /// - Returns: `true` if the authority is loopback; `false` otherwise. + internal static func isLoopback(_ authority: String) -> Bool { + guard let host = host(in: authority) else { + return false + } + return allowed.contains(host) + } + + /// Returns the host portion of `authority`, stripping a trailing port. + /// Returns `nil` for malformed bracketed IPv6 authorities. + private static func host(in authority: String) -> String? { + if authority.hasPrefix("[") { + return bracketedHost(in: authority) + } + let host = authority.split(separator: ":", maxSplits: 1).first + return host.map(String.init) ?? authority + } + + private static func bracketedHost(in authority: String) -> String? { + guard let endBracket = authority.firstIndex(of: "]") else { + return nil + } + let host = String(authority[authority.startIndex...endBracket]) + let afterBracket = authority[authority.index(after: endBracket)...] + if afterBracket.isEmpty { + return host + } + guard afterBracket.hasPrefix(":") else { + return nil + } + return host + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+APITokenOnly.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+APITokenOnly.swift new file mode 100644 index 00000000..96465420 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+APITokenOnly.swift @@ -0,0 +1,57 @@ +// +// MistKitClientFactoryTests+APITokenOnly.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("API Token Only") + internal struct APITokenOnly { + @Test("Create client with API token only") + internal func createWithAPITokenOnly() async throws { + let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "api-token-123") + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + + @Test("Throw error when API token is missing") + internal func throwErrorWhenAPITokenMissing() async throws { + let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "") + + #expect(throws: ConfigurationError.self) { + try MistKitClientFactory.create(for: config) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift new file mode 100644 index 00000000..1544f0cc --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift @@ -0,0 +1,109 @@ +// +// MistKitClientFactoryTests+BadCredentials.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Bad Credentials") + internal struct BadCredentials { + @Test("badCredentials short-circuits to web-auth on private database") + internal func badCredentialsOnPrivateDatabase() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "real-config-token", + database: .private, + webAuthToken: "real-config-web-auth-token", + badCredentials: true + ) + + // Must not throw, even though the configured tokens are unrelated to a real + // CloudKit account — the factory swaps in placeholder tokens for the demo. + _ = try MistKitClientFactory.create(for: config) + } + + @Test("badCredentials short-circuits to web-auth on shared database") + internal func badCredentialsOnSharedDatabase() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "real-config-token", + database: .shared, + webAuthToken: "real-config-web-auth-token", + badCredentials: true + ) + + _ = try MistKitClientFactory.create(for: config) + } + + @Test("badCredentials throws on public database") + internal func badCredentialsOnPublicDatabaseThrows() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "real-config-token", + database: .public(.prefers(.serverToServer)), + keyID: "real-key-id", + privateKey: MistKitClientFactoryTests.validPrivateKey, + badCredentials: true + ) + + do { + _ = try MistKitClientFactory.create(for: config) + Issue.record( + "Should have thrown ConfigurationError.badCredentialsOnPublicDB" + ) + } catch ConfigurationError.badCredentialsOnPublicDB { + // expected + } catch { + Issue.record("Wrong error: \(error)") + } + } + + @Test("badCredentials = false leaves normal auth selection intact") + internal func badCredentialsFalseRegression() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + database: .private, + webAuthToken: "web-auth-token", + badCredentials: false + ) + + _ = try MistKitClientFactory.create(for: config) + } + + @Test("makeBadCredentialsTokenManager produces format-valid tokens") + internal func badCredentialsTokenManagerFormatPassesLocalValidation() async throws { + // The 401 demo only works if the tokens pass WebAuthTokenManager's local + // format check (64-char hex API token + ≥10-char web-auth token) — otherwise + // the request never reaches Apple and httpStatusCode comes back nil. + let manager = MistKitClientFactory.makeBadCredentialsTokenManager() + let validated = try await manager.validateCredentials() + #expect(validated) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ContainerIdentifier.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ContainerIdentifier.swift new file mode 100644 index 00000000..02efd45a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ContainerIdentifier.swift @@ -0,0 +1,51 @@ +// +// MistKitClientFactoryTests+ContainerIdentifier.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Container Identifier") + internal struct ContainerIdentifier { + @Test("Create client with custom container identifier") + internal func createWithCustomContainerIdentifier() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + containerIdentifier: "iCloud.com.custom.App", + apiToken: "api-token" + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift new file mode 100644 index 00000000..1768ba67 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift @@ -0,0 +1,67 @@ +// +// MistKitClientFactoryTests+CustomTokenManager.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Custom Token Manager") + internal struct CustomTokenManager { + @Test("Create client with custom token manager") + internal func createWithCustomTokenManager() async throws { + let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "api-token") + let tokenManager = APITokenManager(apiToken: "custom-token") + + let client = try MistKitClientFactory.create( + from: config, + tokenManager: tokenManager + ) + + #expect(client != nil) + } + + @Test("Create client with custom token manager for public database") + internal func createWithCustomTokenManagerPublicDB() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", database: .public(.prefers(.serverToServer)) + ) + let tokenManager = APITokenManager(apiToken: "custom-token") + + let client = try MistKitClientFactory.create( + from: config, + tokenManager: tokenManager + ) + + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+Environment.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+Environment.swift new file mode 100644 index 00000000..6f43444d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+Environment.swift @@ -0,0 +1,63 @@ +// +// MistKitClientFactoryTests+Environment.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Environment") + internal struct EnvironmentTests { + @Test("Create client with development environment") + internal func createWithDevelopmentEnvironment() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + environment: .development + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + + @Test("Create client with production environment") + internal func createWithProductionEnvironment() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + environment: .production + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ErrorCases.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ErrorCases.swift new file mode 100644 index 00000000..27c5262e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ErrorCases.swift @@ -0,0 +1,95 @@ +// +// MistKitClientFactoryTests+ErrorCases.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Error Cases") + internal struct ErrorCases { + @Test("Missing API token throws ConfigurationError") + internal func missingAPITokenError() async throws { + let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "") + + do { + _ = try MistKitClientFactory.create(for: config) + Issue.record("Should have thrown ConfigurationError") + } catch let error as ConfigurationError { + if case .missingRequired(let key, _) = error { + #expect(key == "api.token") + } else { + Issue.record("Wrong ConfigurationError case") + } + } catch { + Issue.record("Wrong error type") + } + } + + @Test("Empty web auth token throws ConfigurationError") + internal func emptyWebAuthTokenFallback() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + webAuthToken: "" + ) + + #expect(throws: ConfigurationError.self) { + try MistKitClientFactory.create(for: config) + } + } + + @Test("Empty keyID falls back to API-only auth") + internal func emptyKeyIDFallback() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + keyID: "", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + + @Test("Empty private key falls back to API-only auth") + internal func emptyPrivateKeyFallback() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + keyID: "key-id", + privateKey: "" + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+Helpers.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+Helpers.swift new file mode 100644 index 00000000..682d78a9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+Helpers.swift @@ -0,0 +1,91 @@ +// +// MistKitClientFactoryTests+Helpers.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + internal static let validPrivateKey: String = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTest1234567890Test + 1234567890Test1234567890hRACBiCZLT+JFnrEF6+Lq/CBATF/2FJGKe0kWDAuBgNV + BAsTJ0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRQwEgYDVQQD + -----END PRIVATE KEY----- + """ + + internal static func isServerToServerSupported() -> Bool { + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + return true + } else { + return false + } + } + + internal static func makeConfig( + containerIdentifier: String = "iCloud.com.test.App", + apiToken: String = "test-api-token", + environment: MistKit.Environment = .development, + database: MistKit.Database = .private, + webAuthToken: String? = "test-web-auth-token", + keyID: String? = nil, + privateKey: String? = nil, + privateKeyFile: String? = nil, + host: String = "127.0.0.1", + port: Int = 8_080, + authTimeout: Double = 300, + skipAuth: Bool = false, + testAllAuth: Bool = false, + testApiOnly: Bool = false, + testAdaptive: Bool = false, + testServerToServer: Bool = false, + badCredentials: Bool = false + ) async throws -> MistDemoConfig { + try await MistDemoConfig( + containerIdentifier: containerIdentifier, + apiToken: apiToken, + environment: environment, + database: database, + webAuthToken: webAuthToken, + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile, + host: host, + port: port, + authTimeout: authTimeout, + skipAuth: skipAuth, + testAllAuth: testAllAuth, + testApiOnly: testApiOnly, + testAdaptive: testAdaptive, + testServerToServer: testServerToServer, + badCredentials: badCredentials + ) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PrivateKeyFile.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PrivateKeyFile.swift new file mode 100644 index 00000000..4be32f18 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PrivateKeyFile.swift @@ -0,0 +1,54 @@ +// +// MistKitClientFactoryTests+PrivateKeyFile.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Private Key File") + internal struct PrivateKeyFile { + @Test("Load private key from file not implemented") + internal func privateKeyFileNotImplemented() async throws { + // Since loadPrivateKeyFromFile is private and returns nil on error, + // we test the behavior indirectly + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + keyID: "key-id", + privateKeyFile: "/non/existent/file.pem" + ) + + // Should fall back to API-only auth when file can't be read + let client = try? MistKitClientFactory.create(for: config) + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift new file mode 100644 index 00000000..867e38da --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift @@ -0,0 +1,65 @@ +// +// MistKitClientFactoryTests+PublicDatabase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Public Database") + internal struct PublicDatabase { + @Test("Create client for public database") + internal func createForPublicDatabaseTest() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", database: .public(.prefers(.serverToServer)) + ) + let tokenManager = APITokenManager(apiToken: "api-token") + + let client = try MistKitClientFactory.create( + from: config, + tokenManager: tokenManager + ) + + #expect(client != nil) + } + + @Test("Public database creation requires API token") + internal func publicDatabaseRequiresAPIToken() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", database: .public(.prefers(.serverToServer)) + ) + + #expect(throws: ConfigurationError.self) { + try MistKitClientFactory.create(for: config) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ServerToServerAuth.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ServerToServerAuth.swift new file mode 100644 index 00000000..ef8c6de1 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+ServerToServerAuth.swift @@ -0,0 +1,71 @@ +// +// MistKitClientFactoryTests+ServerToServerAuth.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Server-to-Server Auth") + internal struct ServerToServerAuth { + @Test( + "Create client with server-to-server auth", + .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) + ) + internal func createWithServerToServerAuth() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + keyID: "test-key-id", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + + @Test( + "Throw error when server-to-server auth incomplete", + .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) + ) + internal func throwErrorWhenServerToServerIncomplete() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + keyID: "test-key-id" + // privateKey missing + ) + + // Should fall back to API-only auth + let client = try? MistKitClientFactory.create(for: config) + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+WebAuthToken.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+WebAuthToken.swift new file mode 100644 index 00000000..e4b1ed4a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+WebAuthToken.swift @@ -0,0 +1,65 @@ +// +// MistKitClientFactoryTests+WebAuthToken.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistKitClientFactoryTests { + @Suite("Web Auth Token") + internal struct WebAuthToken { + @Test("Create client with web auth token") + internal func createWithWebAuthToken() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + webAuthToken: "web-auth-token" + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + + @Test("Web auth token takes precedence over server-to-server") + internal func webAuthTokenPrecedence() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api-token", + webAuthToken: "web-auth-token", + keyID: "key-id", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let client = try MistKitClientFactory.create(for: config) + + #expect(client != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests.swift new file mode 100644 index 00000000..e128e53f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests.swift @@ -0,0 +1,39 @@ +// +// MistKitClientFactoryTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite( + "MistKitClientFactory", + .disabled( + if: TestPlatform.isWasm32, + "MistKitClientFactory throws .unsupportedPlatform on WASI by design (no URLSession)" + ) +) +internal enum MistKitClientFactoryTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactoryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactoryTests.swift deleted file mode 100644 index 0067ca0d..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactoryTests.swift +++ /dev/null @@ -1,341 +0,0 @@ -// -// MistKitClientFactoryTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo -import MistKit - -@Suite("MistKitClientFactory Tests") -struct MistKitClientFactoryTests { - - // MARK: - Test Config Helpers - - func makeConfig( - containerIdentifier: String = "iCloud.com.test.App", - apiToken: String = "test-api-token", - environment: MistKit.Environment = .development, - webAuthToken: String? = "test-web-auth-token", - keyID: String? = nil, - privateKey: String? = nil, - privateKeyFile: String? = nil, - host: String = "127.0.0.1", - port: Int = 8080, - authTimeout: Double = 300, - skipAuth: Bool = false, - testAllAuth: Bool = false, - testApiOnly: Bool = false, - testAdaptive: Bool = false, - testServerToServer: Bool = false - ) async throws -> MistDemoConfig { - return try await MistDemoConfig( - containerIdentifier: containerIdentifier, - apiToken: apiToken, - environment: environment, - webAuthToken: webAuthToken, - keyID: keyID, - privateKey: privateKey, - privateKeyFile: privateKeyFile, - host: host, - port: port, - authTimeout: authTimeout, - skipAuth: skipAuth, - testAllAuth: testAllAuth, - testApiOnly: testApiOnly, - testAdaptive: testAdaptive, - testServerToServer: testServerToServer - ) - } - - // MARK: - API Token Only Tests - - @Test("Create client with API token only") - func createWithAPITokenOnly() async throws { - let config = try await makeConfig(apiToken: "api-token-123") - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - @Test("Throw error when API token is missing") - func throwErrorWhenAPITokenMissing() async { - let config = try! await makeConfig(apiToken: "") - - #expect(throws: ConfigurationError.self) { - try MistKitClientFactory.create(.private, from: config) - } - } - - // MARK: - Web Auth Token Tests - - @Test("Create client with web auth token") - func createWithWebAuthToken() async throws { - let config = try await makeConfig( - apiToken: "api-token", - webAuthToken: "web-auth-token" - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - @Test("Web auth token takes precedence over server-to-server") - func webAuthTokenPrecedence() async throws { - let config = try await makeConfig( - apiToken: "api-token", - webAuthToken: "web-auth-token", - keyID: "key-id", - privateKey: validPrivateKey - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - // MARK: - Server-to-Server Auth Tests - - @Test("Create client with server-to-server auth", .enabled(if: isServerToServerSupported())) - func createWithServerToServerAuth() async throws { - let config = try await makeConfig( - apiToken: "api-token", - keyID: "test-key-id", - privateKey: validPrivateKey - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - @Test("Throw error when server-to-server auth incomplete", .enabled(if: isServerToServerSupported())) - func throwErrorWhenServerToServerIncomplete() async { - let config = try! await makeConfig( - apiToken: "api-token", - keyID: "test-key-id" - // privateKey missing - ) - - // Should fall back to API-only auth - let client = try? MistKitClientFactory.create(.private, from: config) - #expect(client != nil) - } - - // MARK: - Public Database Tests - - @Test("Create client for public database") - func createForPublicDatabaseTest() async throws { - let config = try await makeConfig(apiToken: "api-token") - let tokenManager = APITokenManager(apiToken: "api-token") - - let client = try MistKitClientFactory.create( - from: config, - tokenManager: tokenManager, - database: .public - ) - - #expect(client != nil) - } - - @Test("Public database creation requires API token") - func publicDatabaseRequiresAPIToken() async { - let config = try! await makeConfig(apiToken: "") - - #expect(throws: ConfigurationError.self) { - try MistKitClientFactory.create(.public, from: config) - } - } - - // MARK: - Custom Token Manager Tests - - @Test("Create client with custom token manager") - func createWithCustomTokenManager() async throws { - let config = try await makeConfig(apiToken: "api-token") - let tokenManager = APITokenManager(apiToken: "custom-token") - - let client = try MistKitClientFactory.create( - from: config, - tokenManager: tokenManager, - database: .private - ) - - #expect(client != nil) - } - - @Test("Create client with custom token manager for public database") - func createWithCustomTokenManagerPublicDB() async throws { - let config = try await makeConfig(apiToken: "api-token") - let tokenManager = APITokenManager(apiToken: "custom-token") - - let client = try MistKitClientFactory.create( - from: config, - tokenManager: tokenManager, - database: .public - ) - - #expect(client != nil) - } - - // MARK: - Environment Tests - - @Test("Create client with development environment") - func createWithDevelopmentEnvironment() async throws { - let config = try await makeConfig( - apiToken: "api-token", - environment: .development - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - @Test("Create client with production environment") - func createWithProductionEnvironment() async throws { - let config = try await makeConfig( - apiToken: "api-token", - environment: .production - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - // MARK: - Container Identifier Tests - - @Test("Create client with custom container identifier") - func createWithCustomContainerIdentifier() async throws { - let config = try await makeConfig( - containerIdentifier: "iCloud.com.custom.App", - apiToken: "api-token" - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - // MARK: - Private Key File Tests - - @Test("Load private key from file not implemented") - func privateKeyFileNotImplemented() async throws { - // Since loadPrivateKeyFromFile is private and returns nil on error, - // we test the behavior indirectly - let config = try await makeConfig( - apiToken: "api-token", - keyID: "key-id", - privateKeyFile: "/non/existent/file.pem" - ) - - // Should fall back to API-only auth when file can't be read - let client = try? MistKitClientFactory.create(.private, from: config) - #expect(client != nil) - } - - // MARK: - Error Cases - - @Test("Missing API token throws ConfigurationError") - func missingAPITokenError() async { - let config = try! await makeConfig(apiToken: "") - - do { - _ = try MistKitClientFactory.create(.private, from: config) - Issue.record("Should have thrown ConfigurationError") - } catch let error as ConfigurationError { - if case .missingRequired(let key, _) = error { - #expect(key == "api.token") - } else { - Issue.record("Wrong ConfigurationError case") - } - } catch { - Issue.record("Wrong error type") - } - } - - @Test("Empty web auth token throws ConfigurationError") - func emptyWebAuthTokenFallback() async { - let config = try! await makeConfig( - apiToken: "api-token", - webAuthToken: "" - ) - - #expect(throws: ConfigurationError.self) { - try MistKitClientFactory.create(.private, from: config) - } - } - - @Test("Empty keyID falls back to API-only auth") - func emptyKeyIDFallback() async throws { - let config = try await makeConfig( - apiToken: "api-token", - keyID: "", - privateKey: validPrivateKey - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - @Test("Empty private key falls back to API-only auth") - func emptyPrivateKeyFallback() async throws { - let config = try await makeConfig( - apiToken: "api-token", - keyID: "key-id", - privateKey: "" - ) - - let client = try MistKitClientFactory.create(.private, from: config) - - #expect(client != nil) - } - - // MARK: - Helper Functions - - static func isServerToServerSupported() -> Bool { - if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { - return true - } else { - return false - } - } - - var validPrivateKey: String { - """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTest1234567890Test - 1234567890Test1234567890hRACBiCZLT+JFnrEF6+Lq/CBATF/2FJGKe0kWDAuBgNV - BAsTJ0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRQwEgYDVQQD - -----END PRIVATE KEY----- - """ - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+AsyncChannel.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+AsyncChannel.swift new file mode 100644 index 00000000..e6566b45 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+AsyncChannel.swift @@ -0,0 +1,69 @@ +// +// AuthTokenCommandTests+AsyncChannel.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import AsyncAlgorithms + import Foundation + import Testing + + @testable import MistDemoKit + + extension AuthTokenCommandTests { + @Suite("AsyncChannel") + internal struct AsyncChannelTests { + @Test("AsyncChannel sends and receives values") + internal func asyncChannelSendsAndReceives() async { + let channel = AsyncChannel() + + Task { + await channel.send("test-value") + } + + var iterator = channel.makeAsyncIterator() + let value = await iterator.next() + #expect(value == "test-value") + } + + @Test("AsyncChannel handles multiple values sequentially") + internal func asyncChannelHandlesMultipleValues() async { + let channel = AsyncChannel() + var iterator = channel.makeAsyncIterator() + + Task { await channel.send(1) } + #expect(await iterator.next() == 1) + + Task { await channel.send(2) } + #expect(await iterator.next() == 2) + + Task { await channel.send(3) } + #expect(await iterator.next() == 3) + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+CommandInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+CommandInitialization.swift new file mode 100644 index 00000000..fc49d7eb --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+CommandInitialization.swift @@ -0,0 +1,50 @@ +// +// AuthTokenCommandTests+CommandInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import Testing + + @testable import MistDemoKit + + extension AuthTokenCommandTests { + @Suite("Command Initialization") + internal struct CommandInitialization { + @Test("Command initializes with config") + internal func commandInitializesWithConfig() { + let config = AuthTokenConfig(apiToken: "test-api-token") + _ = AuthTokenCommand(config: config) + + // Command should be created successfully + #expect(AuthTokenCommand.commandName == "auth-token") + #expect(AuthTokenCommand.abstract == "Obtain a web authentication token via browser flow") + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift new file mode 100644 index 00000000..b07e39a5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift @@ -0,0 +1,65 @@ +// +// AuthTokenCommandTests+Configuration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import Testing + + @testable import MistDemoKit + + extension AuthTokenCommandTests { + @Suite("Configuration") + internal struct Configuration { + @Test("AuthTokenConfig initializes with default values") + internal func authTokenConfigInitializesWithDefaults() { + let config = AuthTokenConfig(apiToken: "test-token") + + #expect(config.apiToken == "test-token") + #expect(config.port == 8_080) + #expect(config.host == "127.0.0.1") + #expect(config.openBrowser == true) + } + + @Test("AuthTokenConfig accepts custom values") + internal func authTokenConfigAcceptsCustomValues() { + let config = AuthTokenConfig( + apiToken: "custom-token", + port: 3_000, + host: "localhost", + openBrowser: false + ) + + #expect(config.apiToken == "custom-token") + #expect(config.port == 3_000) + #expect(config.host == "localhost") + #expect(config.openBrowser == false) + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Error.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Error.swift new file mode 100644 index 00000000..f0b5396b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Error.swift @@ -0,0 +1,62 @@ +// +// AuthTokenCommandTests+Error.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import Testing + + @testable import MistDemoKit + + extension AuthTokenCommandTests { + @Suite("Error") + internal struct ErrorTests { + @Test("AuthTokenError timeout has correct description") + internal func authTokenErrorTimeoutDescription() { + let error = AuthTokenError.timeout("Operation timed out after 5 minutes") + + #expect( + error.errorDescription == "Authentication timeout: Operation timed out after 5 minutes") + } + + @Test("AuthTokenError missing resource has correct description") + internal func authTokenErrorMissingResourceDescription() { + let error = AuthTokenError.missingResource("index.html not found") + + #expect(error.errorDescription == "Missing resource: index.html not found") + } + + @Test("AuthTokenError server error has correct description") + internal func authTokenErrorServerErrorDescription() { + let error = AuthTokenError.serverError("Failed to bind to port") + + #expect(error.errorDescription == "Server error: Failed to bind to port") + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift new file mode 100644 index 00000000..4eff29f5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift @@ -0,0 +1,56 @@ +// +// AuthTokenCommandTests+MockServer.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import Testing + + @testable import MistDemoKit + + extension AuthTokenCommandTests { + @Suite("Mock Server") + internal struct MockServer { + @Test("AuthRequest decodes correctly") + internal func authRequestDecodesCorrectly() throws { + let json = """ + { + "sessionToken": "mock-session-token", + "userRecordName": "user123" + } + """ + + let data = Data(json.utf8) + let request = try JSONDecoder().decode(AuthRequest.self, from: data) + + #expect(request.sessionToken == "mock-session-token") + #expect(request.userRecordName == "user123") + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift new file mode 100644 index 00000000..3c9dc7b7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift @@ -0,0 +1,71 @@ +// +// AuthTokenCommandTests+Timeout.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import Testing + + @testable import MistDemoKit + + extension AuthTokenCommandTests { + @Suite("Timeout") + internal struct TimeoutTests { + @Test( + "Timeout helper throws on timeout", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't fire the timeout race against an inner Task.sleep" + ) + ) + internal func timeoutHelperThrowsOnTimeout() async { + // Mirrors AsyncHelpersTests+Timeout's gate: on simulator cooperative + // executors (notably visionOS / watchOS under CI load) the operation's + // single long Task.sleep can complete before the polling timeout + // task's many short sleeps detect the deadline. + await withKnownIssue(isIntermittent: true) { + await #expect(throws: AsyncTimeoutError.self) { + try await withTimeoutAndSignals(seconds: 0.1) { + try await Task.sleep(nanoseconds: 1_000_000_000) + return "should-not-return" + } + } + } + } + + @Test("Timeout helper returns value before timeout") + internal func timeoutHelperReturnsValue() async throws { + let result = try await withTimeoutAndSignals(seconds: 1.0) { + "success" + } + + #expect(result == "success") + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests.swift new file mode 100644 index 00000000..81cb23b0 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests.swift @@ -0,0 +1,40 @@ +// +// AuthTokenCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Testing + + @Suite("AuthTokenCommand") + internal enum AuthTokenCommandTests {} +#endif + +// MARK: - Mock HTTP Context for Testing + +// Tests for AuthTokenCommand HTTP functionality would require more complex mocking +// These tests focus on the configuration and error handling aspects diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommandTests.swift deleted file mode 100644 index 4862e3d4..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommandTests.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// AuthTokenCommandTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import Hummingbird -import HTTPTypes -import MistKit - -@testable import MistDemo - -@Suite("AuthTokenCommand Tests") -struct AuthTokenCommandTests { - // MARK: - Configuration Tests - - @Test("AuthTokenConfig initializes with default values") - func authTokenConfigInitializesWithDefaults() { - let config = AuthTokenConfig(apiToken: "test-token") - - #expect(config.apiToken == "test-token") - #expect(config.port == 8080) - #expect(config.host == "127.0.0.1") - #expect(config.noBrowser == false) - } - - @Test("AuthTokenConfig accepts custom values") - func authTokenConfigAcceptsCustomValues() { - let config = AuthTokenConfig( - apiToken: "custom-token", - port: 3000, - host: "localhost", - noBrowser: true - ) - - #expect(config.apiToken == "custom-token") - #expect(config.port == 3000) - #expect(config.host == "localhost") - #expect(config.noBrowser == true) - } - - // MARK: - Error Tests - - @Test("AuthTokenError timeout has correct description") - func authTokenErrorTimeoutDescription() { - let error = AuthTokenError.timeout("Operation timed out after 5 minutes") - - #expect(error.errorDescription == "Authentication timeout: Operation timed out after 5 minutes") - } - - @Test("AuthTokenError missing resource has correct description") - func authTokenErrorMissingResourceDescription() { - let error = AuthTokenError.missingResource("index.html not found") - - #expect(error.errorDescription == "Missing resource: index.html not found") - } - - @Test("AuthTokenError server error has correct description") - func authTokenErrorServerErrorDescription() { - let error = AuthTokenError.serverError("Failed to bind to port") - - #expect(error.errorDescription == "Server error: Failed to bind to port") - } - - // MARK: - Mock Server Tests - - @Test("AuthRequest decodes correctly") - func authRequestDecodesCorrectly() throws { - let json = """ - { - "sessionToken": "mock-session-token", - "userRecordName": "user123" - } - """ - - let data = Data(json.utf8) - let request = try JSONDecoder().decode(AuthRequest.self, from: data) - - #expect(request.sessionToken == "mock-session-token") - #expect(request.userRecordName == "user123") - } - - @Test("AuthResponse encodes correctly") - func authResponseEncodesCorrectly() throws { - let response = AuthResponse( - userRecordName: "user123", - cloudKitData: CloudKitData(user: nil, zones: [], error: nil), - message: "Success" - ) - - let data = try JSONEncoder().encode(response) - - // Verify the encoded data is not empty - #expect(!data.isEmpty) - } - - // MARK: - Command Initialization Tests - - @Test("Command initializes with config") - func commandInitializesWithConfig() { - let config = AuthTokenConfig(apiToken: "test-api-token") - let _ = AuthTokenCommand(config: config) - - // Command should be created successfully - #expect(AuthTokenCommand.commandName == "auth-token") - #expect(AuthTokenCommand.abstract == "Obtain a web authentication token via browser flow") - } - - // MARK: - API Token Masking Tests - - @Test("API token masking works correctly") - func apiTokenMaskingWorks() { - let shortToken = "abc" - #expect(shortToken.maskedAPIToken == "***") - - let mediumToken = "abcdef" - #expect(mediumToken.maskedAPIToken == "ab****") - - let longToken = "abcdefghijklmnop" - #expect(longToken.maskedAPIToken == "ab************op") - } - - // MARK: - AsyncChannel Tests - - @Test("AsyncChannel sends and receives values") - func asyncChannelSendsAndReceives() async { - let channel = AsyncChannel() - - Task { - await channel.send("test-value") - } - - let value = await channel.receive() - #expect(value == "test-value") - } - - @Test("AsyncChannel handles multiple values sequentially") - func asyncChannelHandlesMultipleValues() async { - let channel = AsyncChannel() - - Task { await channel.send(1) } - let first = await channel.receive() - #expect(first == 1) - - Task { await channel.send(2) } - let second = await channel.receive() - #expect(second == 2) - - Task { await channel.send(3) } - let third = await channel.receive() - #expect(third == 3) - } - - // MARK: - Timeout Tests - - @Test("Timeout helper throws on timeout") - func timeoutHelperThrowsOnTimeout() async throws { - do { - _ = try await withTimeoutAndSignals(seconds: 0.1) { - try await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second - return "should-not-return" - } - Issue.record("Should have timed out") - } catch is AsyncTimeoutError { - // Expected timeout error - #expect(Bool(true)) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test("Timeout helper returns value before timeout") - func timeoutHelperReturnsValue() async throws { - let result = try await withTimeoutAndSignals(seconds: 1.0) { - return "success" - } - - #expect(result == "success") - } -} - -// MARK: - Mock HTTP Context for Testing - -// Tests for AuthTokenCommand HTTP functionality would require more complex mocking -// These tests focus on the configuration and error handling aspects \ No newline at end of file diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift new file mode 100644 index 00000000..899e769b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift @@ -0,0 +1,67 @@ +// +// CommandIntegrationTests+AuthTokenCommandIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import MistKit + import Testing + + @testable import MistDemoKit + + extension CommandIntegrationTests { + @Suite("AuthTokenCommand Integration") + internal struct AuthTokenCommandIntegration { + @Test("AuthTokenCommand configuration validation") + internal func authTokenCommandConfigValidation() async throws { + let config = AuthTokenConfig( + apiToken: "test-api-token-123", + port: 8_080, + host: "127.0.0.1", + openBrowser: false + ) + + _ = AuthTokenCommand(config: config) + + // Verify command is properly configured + #expect(AuthTokenCommand.commandName == "auth-token") + #expect(AuthTokenCommand.abstract.contains("authentication token")) + } + + @Test("AuthTokenCommand resource path validation") + internal func authTokenCommandResourcePathValidation() async throws { + let config = AuthTokenConfig(apiToken: "test-token") + _ = AuthTokenCommand(config: config) + + // Test that resource finding logic doesn't crash + // This tests the findResourcesPath method indirectly + #expect(AuthTokenCommand.commandName == "auth-token") + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CreateCommandIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CreateCommandIntegration.swift new file mode 100644 index 00000000..7cb9c9e9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CreateCommandIntegration.swift @@ -0,0 +1,101 @@ +// +// CommandIntegrationTests+CreateCommandIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CommandIntegrationTests { + @Suite("CreateCommand Integration") + internal struct CreateCommandIntegration { + private static func createTestConfig() async throws -> MistDemoConfig { + try await MistDemoConfig() + } + + @Test("CreateCommand with parsed fields") + internal func createCommandWithParsedFields() async throws { + let baseConfig = try await Self.createTestConfig() + let fields = [ + try Field(parsing: "title:string:Integration Test Note"), + try Field(parsing: "priority:int64:8"), + try Field(parsing: "progress:double:0.85"), + ] + + let config = CreateConfig( + base: baseConfig, + zone: "_defaultZone", + recordName: "test-record-123", + fields: fields + ) + + _ = CreateCommand(config: config) + + // Verify create configuration + #expect(CreateCommand.commandName == "create") + #expect(config.fields.count == 3) + #expect(config.recordName == "test-record-123") + + // Verify field parsing + let titleField = config.fields.first { $0.name == "title" } + #expect(titleField?.type == .string) + #expect(titleField?.value == "Integration Test Note") + } + + @Test("CreateCommand field type validation") + internal func createCommandFieldTypeValidation() async throws { + let baseConfig = try await Self.createTestConfig() + + // Test different field types + let stringField = try Field(parsing: "description:string:This is a test description") + let intField = try Field(parsing: "count:int64:42") + let doubleField = try Field(parsing: "rating:double:4.5") + let timestampField = try Field(parsing: "deadline:timestamp:2026-12-31T23:59:59Z") + + let config = CreateConfig( + base: baseConfig, + zone: "_defaultZone", + recordName: nil, + fields: [stringField, intField, doubleField, timestampField] + ) + + _ = CreateCommand(config: config) + + #expect(config.fields.count == 4) + + // Verify each field type + let fieldTypes = config.fields.map(\.type) + #expect(fieldTypes.contains(.string)) + #expect(fieldTypes.contains(.int64)) + #expect(fieldTypes.contains(.double)) + #expect(fieldTypes.contains(.timestamp)) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CrossCommandIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CrossCommandIntegration.swift new file mode 100644 index 00000000..2684d1de --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CrossCommandIntegration.swift @@ -0,0 +1,84 @@ +// +// CommandIntegrationTests+CrossCommandIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CommandIntegrationTests { + @Suite("Cross-Command Integration") + internal struct CrossCommandIntegration { + private static func createTestConfig() async throws -> MistDemoConfig { + try await MistDemoConfig() + } + + @Test("Configuration consistency across commands") + internal func configurationConsistencyAcrossCommands() async throws { + let baseConfig = try await Self.createTestConfig() + + // Create configs for all commands + _ = AuthTokenConfig(apiToken: "test-token") + let userConfig = CurrentUserConfig(base: baseConfig) + let queryConfig = QueryConfig(base: baseConfig) + let createConfig = CreateConfig( + base: baseConfig, zone: "_defaultZone", recordName: nil, fields: [] + ) + + // Verify all use same base container + #expect(userConfig.base.containerIdentifier == baseConfig.containerIdentifier) + #expect(queryConfig.base.containerIdentifier == baseConfig.containerIdentifier) + #expect(createConfig.base.containerIdentifier == baseConfig.containerIdentifier) + + // Verify environment consistency + #expect(userConfig.base.environment == .development) + #expect(queryConfig.base.environment == .development) + #expect(createConfig.base.environment == .development) + } + + @Test("Output format consistency") + internal func outputFormatConsistency() async throws { + let baseConfig = try await Self.createTestConfig() + + let userConfig = CurrentUserConfig(base: baseConfig, output: .json) + let queryConfig = QueryConfig(base: baseConfig, output: .json) + + #expect(userConfig.output == .json) + #expect(queryConfig.output == .json) + + // Test other formats + let csvUserConfig = CurrentUserConfig(base: baseConfig, output: .csv) + let csvQueryConfig = QueryConfig(base: baseConfig, output: .csv) + + #expect(csvUserConfig.output == .csv) + #expect(csvQueryConfig.output == .csv) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CurrentUserCommandIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CurrentUserCommandIntegration.swift new file mode 100644 index 00000000..cb2fcecf --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+CurrentUserCommandIntegration.swift @@ -0,0 +1,80 @@ +// +// CommandIntegrationTests+CurrentUserCommandIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CommandIntegrationTests { + @Suite("CurrentUserCommand Integration") + internal struct CurrentUserCommandIntegration { + private static func createTestConfig() async throws -> MistDemoConfig { + try await MistDemoConfig() + } + + @Test("CurrentUserCommand end-to-end flow") + internal func currentUserCommandEndToEndFlow() async throws { + let baseConfig = try await Self.createTestConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["userRecordName", "emailAddress"], + output: .json + ) + + _ = CurrentUserCommand(config: config) + + // Verify command configuration + #expect(CurrentUserCommand.commandName == "current-user") + + // Verify config properties + #expect(config.fields?.count == 2) + #expect(config.output == .json) + } + + @Test("CurrentUserCommand with field filtering") + internal func currentUserCommandWithFieldFiltering() async throws { + let baseConfig = try await Self.createTestConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["userRecordName", "firstName", "lastName"], + output: .table + ) + + _ = CurrentUserCommand(config: config) + + // Verify field filtering setup + #expect(config.fields?.contains("userRecordName") == true) + #expect(config.fields?.contains("firstName") == true) + #expect(config.fields?.contains("lastName") == true) + #expect(config.output == .table) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+ErrorHandlingIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+ErrorHandlingIntegration.swift new file mode 100644 index 00000000..3fcb4ef7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+ErrorHandlingIntegration.swift @@ -0,0 +1,61 @@ +// +// CommandIntegrationTests+ErrorHandlingIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CommandIntegrationTests { + @Suite("Error Handling Integration") + internal struct ErrorHandlingIntegration { + @Test("Authentication error propagation") + internal func authenticationErrorPropagation() async throws { + let authError = MistDemoError.authenticationFailed( + description: "Invalid token", + context: "integration-test" + ) + + #expect(authError.errorCode == "AUTHENTICATION_FAILED") + #expect(authError.errorDescription?.contains("integration-test") == true) + #expect(authError.recoverySuggestion != nil) + } + + @Test("Configuration error handling") + internal func configurationErrorHandling() async throws { + let configError = ConfigurationError.missingRequired( + "api.token", + suggestion: "Provide token via --api-token" + ) + + #expect(configError.errorDescription?.contains("api.token") == true) + #expect(configError.errorDescription?.contains("Provide token via --api-token") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+QueryCommandIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+QueryCommandIntegration.swift new file mode 100644 index 00000000..308df2e5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+QueryCommandIntegration.swift @@ -0,0 +1,84 @@ +// +// CommandIntegrationTests+QueryCommandIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CommandIntegrationTests { + @Suite("QueryCommand Integration") + internal struct QueryCommandIntegration { + private static func createTestConfig() async throws -> MistDemoConfig { + try await MistDemoConfig() + } + + @Test("QueryCommand with filters and sorting") + internal func queryCommandWithFiltersAndSorting() async throws { + let baseConfig = try await Self.createTestConfig() + let config = QueryConfig( + base: baseConfig, + zone: "_defaultZone", + recordType: "Note", + filters: ["title:contains:Test", "priority:gt:3"], + sort: (field: "createdAt", order: .descending), + limit: 50, + fields: ["title", "content", "createdAt"] + ) + + _ = QueryCommand(config: config) + + // Verify query configuration + #expect(QueryCommand.commandName == "query") + #expect(config.filters.count == 2) + #expect(config.sort?.field == "createdAt") + #expect(config.sort?.order == .descending) + #expect(config.limit == 50) + } + + @Test("QueryCommand pagination setup") + internal func queryCommandPaginationSetup() async throws { + let baseConfig = try await Self.createTestConfig() + let config = QueryConfig( + base: baseConfig, + limit: 10, + offset: 20, + continuationMarker: "next-page-token" + ) + + _ = QueryCommand(config: config) + + // Verify pagination configuration + #expect(config.limit == 10) + #expect(config.offset == 20) + #expect(config.continuationMarker == "next-page-token") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift new file mode 100644 index 00000000..a9a1f579 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift @@ -0,0 +1,86 @@ +// +// CommandIntegrationTests+RealWorldUsageSimulation.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CommandIntegrationTests { + @Suite("Real-world Usage Simulation") + internal struct RealWorldUsageSimulation { + private static func createTestConfig() async throws -> MistDemoConfig { + try await MistDemoConfig() + } + + @Test("Simulate complete workflow") + internal func simulateCompleteWorkflow() async throws { + // 1. Auth token configuration + #if canImport(Hummingbird) + let authConfig = AuthTokenConfig( + apiToken: "mock-api-token-for-test", + openBrowser: false + ) + _ = AuthTokenCommand(config: authConfig) + #endif + + // 2. Current user check + let baseConfig = try await Self.createTestConfig() + let userConfig = CurrentUserConfig(base: baseConfig) + _ = CurrentUserCommand(config: userConfig) + + // 3. Query existing records + let queryConfig = QueryConfig( + base: baseConfig, + filters: ["title:contains:test"], + limit: 10 + ) + _ = QueryCommand(config: queryConfig) + + // 4. Create new record + let fields = [try Field(parsing: "title:string:Workflow Test")] + let createConfig = CreateConfig( + base: baseConfig, + zone: "_defaultZone", + recordName: nil, + fields: fields + ) + _ = CreateCommand(config: createConfig) + + // Verify all commands are properly configured + #if canImport(Hummingbird) + #expect(AuthTokenCommand.commandName == "auth-token") + #endif + #expect(CurrentUserCommand.commandName == "current-user") + #expect(QueryCommand.commandName == "query") + #expect(CreateCommand.commandName == "create") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests.swift new file mode 100644 index 00000000..80528183 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests.swift @@ -0,0 +1,35 @@ +// +// CommandIntegrationTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("Command Integration") +internal enum CommandIntegrationTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegrationTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegrationTests.swift deleted file mode 100644 index abc9a0d1..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegrationTests.swift +++ /dev/null @@ -1,345 +0,0 @@ -// -// CommandIntegrationTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("Command Integration Tests") -struct CommandIntegrationTests { - // MARK: - Test Configuration - - private func createTestConfig() async throws -> MistDemoConfig { - return try await MistDemoConfig() - } - - private func createMockAuthResult() throws -> AuthenticationResult { - let mockTokenManager = MockCommandTokenManager() - return AuthenticationResult( - tokenManager: mockTokenManager, - database: .private, - authMethod: "mock-auth" - ) - } - - // MARK: - AuthTokenCommand Integration Tests - - @Test("AuthTokenCommand configuration validation") - func authTokenCommandConfigValidation() async throws { - let config = AuthTokenConfig( - apiToken: "test-api-token-123", - port: 8080, - host: "127.0.0.1", - noBrowser: true - ) - - let _ = AuthTokenCommand(config: config) - - // Verify command is properly configured - #expect(AuthTokenCommand.commandName == "auth-token") - #expect(AuthTokenCommand.abstract.contains("authentication token")) - } - - @Test("AuthTokenCommand resource path validation") - func authTokenCommandResourcePathValidation() async throws { - let config = AuthTokenConfig(apiToken: "test-token") - let _ = AuthTokenCommand(config: config) - - // Test that resource finding logic doesn't crash - // This tests the findResourcesPath method indirectly - #expect(AuthTokenCommand.commandName == "auth-token") - } - - // MARK: - CurrentUserCommand Integration Tests - - @Test("CurrentUserCommand end-to-end flow") - func currentUserCommandEndToEndFlow() async throws { - let baseConfig = try await createTestConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["userRecordName", "emailAddress"], - output: .json - ) - - let _ = CurrentUserCommand(config: config) - - // Verify command configuration - #expect(CurrentUserCommand.commandName == "current-user") - - // Verify config properties - #expect(config.fields?.count == 2) - #expect(config.output == .json) - } - - @Test("CurrentUserCommand with field filtering") - func currentUserCommandWithFieldFiltering() async throws { - let baseConfig = try await createTestConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["userRecordName", "firstName", "lastName"], - output: .table - ) - - let _ = CurrentUserCommand(config: config) - - // Verify field filtering setup - #expect(config.fields?.contains("userRecordName") == true) - #expect(config.fields?.contains("firstName") == true) - #expect(config.fields?.contains("lastName") == true) - #expect(config.output == .table) - } - - // MARK: - QueryCommand Integration Tests - - @Test("QueryCommand with filters and sorting") - func queryCommandWithFiltersAndSorting() async throws { - let baseConfig = try await createTestConfig() - let config = QueryConfig( - base: baseConfig, - zone: "_defaultZone", - recordType: "Note", - filters: ["title:contains:Test", "priority:gt:3"], - sort: (field: "createdAt", order: .descending), - limit: 50, - fields: ["title", "content", "createdAt"] - ) - - let _ = QueryCommand(config: config) - - // Verify query configuration - #expect(QueryCommand.commandName == "query") - #expect(config.filters.count == 2) - #expect(config.sort?.field == "createdAt") - #expect(config.sort?.order == .descending) - #expect(config.limit == 50) - } - - @Test("QueryCommand pagination setup") - func queryCommandPaginationSetup() async throws { - let baseConfig = try await createTestConfig() - let config = QueryConfig( - base: baseConfig, - limit: 10, - offset: 20, - continuationMarker: "next-page-token" - ) - - let _ = QueryCommand(config: config) - - // Verify pagination configuration - #expect(config.limit == 10) - #expect(config.offset == 20) - #expect(config.continuationMarker == "next-page-token") - } - - // MARK: - CreateCommand Integration Tests - - @Test("CreateCommand with parsed fields") - func createCommandWithParsedFields() async throws { - let baseConfig = try await createTestConfig() - let fields = [ - try Field(parsing: "title:string:Integration Test Note"), - try Field(parsing: "priority:int64:8"), - try Field(parsing: "progress:double:0.85") - ] - - let config = CreateConfig( - base: baseConfig, - zone: "_defaultZone", - recordName: "test-record-123", - fields: fields - ) - - let _ = CreateCommand(config: config) - - // Verify create configuration - #expect(CreateCommand.commandName == "create") - #expect(config.fields.count == 3) - #expect(config.recordName == "test-record-123") - - // Verify field parsing - let titleField = config.fields.first { $0.name == "title" } - #expect(titleField?.type == .string) - #expect(titleField?.value == "Integration Test Note") - } - - @Test("CreateCommand field type validation") - func createCommandFieldTypeValidation() async throws { - let baseConfig = try await createTestConfig() - - // Test different field types - let stringField = try Field(parsing: "description:string:This is a test description") - let intField = try Field(parsing: "count:int64:42") - let doubleField = try Field(parsing: "rating:double:4.5") - let timestampField = try Field(parsing: "deadline:timestamp:2026-12-31T23:59:59Z") - - let config = CreateConfig( - base: baseConfig, - zone: "_defaultZone", - recordName: nil, - fields: [stringField, intField, doubleField, timestampField] - ) - - let _ = CreateCommand(config: config) - - #expect(config.fields.count == 4) - - // Verify each field type - let fieldTypes = config.fields.map(\.type) - #expect(fieldTypes.contains(.string)) - #expect(fieldTypes.contains(.int64)) - #expect(fieldTypes.contains(.double)) - #expect(fieldTypes.contains(.timestamp)) - } - - // MARK: - Cross-Command Integration Tests - - @Test("Configuration consistency across commands") - func configurationConsistencyAcrossCommands() async throws { - let baseConfig = try await createTestConfig() - - // Create configs for all commands - let _ = AuthTokenConfig(apiToken: "test-token") - let userConfig = CurrentUserConfig(base: baseConfig) - let queryConfig = QueryConfig(base: baseConfig) - let createConfig = CreateConfig(base: baseConfig, zone: "_defaultZone", recordName: nil, fields: []) - - // Verify all use same base container - #expect(userConfig.base.containerIdentifier == baseConfig.containerIdentifier) - #expect(queryConfig.base.containerIdentifier == baseConfig.containerIdentifier) - #expect(createConfig.base.containerIdentifier == baseConfig.containerIdentifier) - - // Verify environment consistency - #expect(userConfig.base.environment == .development) - #expect(queryConfig.base.environment == .development) - #expect(createConfig.base.environment == .development) - } - - @Test("Output format consistency") - func outputFormatConsistency() async throws { - let baseConfig = try await createTestConfig() - - let userConfig = CurrentUserConfig(base: baseConfig, output: .json) - let queryConfig = QueryConfig(base: baseConfig, output: .json) - - #expect(userConfig.output == .json) - #expect(queryConfig.output == .json) - - // Test other formats - let csvUserConfig = CurrentUserConfig(base: baseConfig, output: .csv) - let csvQueryConfig = QueryConfig(base: baseConfig, output: .csv) - - #expect(csvUserConfig.output == .csv) - #expect(csvQueryConfig.output == .csv) - } - - // MARK: - Error Handling Integration Tests - - @Test("Authentication error propagation") - func authenticationErrorPropagation() async throws { - let authError = MistDemoError.authenticationFailed( - description: "Invalid token", - context: "integration-test" - ) - - #expect(authError.errorCode == "AUTHENTICATION_FAILED") - #expect(authError.errorDescription?.contains("integration-test") == true) - #expect(authError.recoverySuggestion != nil) - } - - @Test("Configuration error handling") - func configurationErrorHandling() async throws { - let configError = ConfigurationError.missingRequired( - "api.token", - suggestion: "Provide token via --api-token" - ) - - #expect(configError.errorDescription?.contains("api.token") == true) - #expect(configError.errorDescription?.contains("Provide token via --api-token") == true) - } - - // MARK: - Real-world Usage Simulation - - @Test("Simulate complete workflow") - func simulateCompleteWorkflow() async throws { - // 1. Auth token configuration - let authConfig = AuthTokenConfig( - apiToken: "mock-api-token-for-test", - noBrowser: true - ) - let _ = AuthTokenCommand(config: authConfig) - - // 2. Current user check - let baseConfig = try await createTestConfig() - let userConfig = CurrentUserConfig(base: baseConfig) - let _ = CurrentUserCommand(config: userConfig) - - // 3. Query existing records - let queryConfig = QueryConfig( - base: baseConfig, - filters: ["title:contains:test"], - limit: 10 - ) - let _ = QueryCommand(config: queryConfig) - - // 4. Create new record - let fields = [try Field(parsing: "title:string:Workflow Test")] - let createConfig = CreateConfig( - base: baseConfig, - zone: "_defaultZone", - recordName: nil, - fields: fields - ) - let _ = CreateCommand(config: createConfig) - - // Verify all commands are properly configured - #expect(AuthTokenCommand.commandName == "auth-token") - #expect(CurrentUserCommand.commandName == "current-user") - #expect(QueryCommand.commandName == "query") - #expect(CreateCommand.commandName == "create") - } -} - -// MARK: - Mock Token Manager for Integration Tests - -internal final class MockCommandTokenManager: TokenManager { - var hasCredentials: Bool { - get async { true } - } - - func validateCredentials() async throws(TokenManagerError) -> Bool { - return true - } - - func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - return .webAuthToken(apiToken: "mock-api", webToken: "mock-web-auth") - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+CommandProperty.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+CommandProperty.swift new file mode 100644 index 00000000..c10376be --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+CommandProperty.swift @@ -0,0 +1,59 @@ +// +// CreateCommandTests+CommandProperty.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Command Property") + internal struct CommandProperty { + @Test("Command has correct static properties") + internal func commandHasCorrectStaticProperties() { + #expect(CreateCommand.commandName == "create") + #expect(CreateCommand.abstract == "Create a new record in CloudKit") + } + + @Test("Command initializes with config") + internal func commandInitializesWithConfig() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + zone: "_defaultZone", + recordName: nil, + fields: [] + ) + _ = CreateCommand(config: config) + + #expect(CreateCommand.commandName == "create") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+Configuration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+Configuration.swift new file mode 100644 index 00000000..da897fd3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+Configuration.swift @@ -0,0 +1,73 @@ +// +// CreateCommandTests+Configuration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Configuration") + internal struct Configuration { + @Test("CreateConfig initializes with default values") + internal func createConfigInitializesWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + zone: "_defaultZone", + recordName: nil, + fields: [] + ) + + #expect(config.zone == "_defaultZone") + #expect(config.recordName == nil) + #expect(config.fields.isEmpty) + } + + @Test("CreateConfig accepts custom values") + internal func createConfigAcceptsCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let fields = [ + Field(name: "title", type: .string, value: "Test Note"), + Field(name: "priority", type: .int64, value: "5"), + ] + let config = CreateConfig( + base: baseConfig, + zone: "customZone", + recordName: "customRecord", + fields: fields + ) + + #expect(config.zone == "customZone") + #expect(config.recordName == "customRecord") + #expect(config.fields.count == 2) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+ErrorHandling.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+ErrorHandling.swift new file mode 100644 index 00000000..fedb237a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+ErrorHandling.swift @@ -0,0 +1,51 @@ +// +// CreateCommandTests+ErrorHandling.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Error Handling") + internal struct ErrorHandling { + @Test("CreateError cases") + internal func createErrorCases() { + let parseError = CreateError.invalidJSONFormat("Invalid JSON format") + let fileError = CreateError.jsonFileError("test.json", "File not found") + let conversionError = CreateError.fieldConversionError( + "field", .string, "value", "Conversion failed" + ) + + #expect(parseError.errorDescription?.contains("Invalid JSON format") == true) + #expect(fileError.errorDescription?.contains("File not found") == true) + #expect(conversionError.errorDescription?.contains("Conversion failed") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldParsing.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldParsing.swift new file mode 100644 index 00000000..343583cf --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldParsing.swift @@ -0,0 +1,92 @@ +// +// CreateCommandTests+FieldParsing.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Field Parsing") + internal struct FieldParsing { + @Test("Parse string field") + internal func parseStringField() async throws { + let field = try Field(parsing: "title:string:My Note") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == "My Note") + } + + @Test("Parse int64 field") + internal func parseInt64Field() async throws { + let field = try Field(parsing: "priority:int64:5") + + #expect(field.name == "priority") + #expect(field.type == .int64) + #expect(field.value == "5") + } + + @Test("Parse double field") + internal func parseDoubleField() async throws { + let field = try Field(parsing: "progress:double:0.75") + + #expect(field.name == "progress") + #expect(field.type == .double) + #expect(field.value == "0.75") + } + + @Test("Parse timestamp field") + internal func parseTimestampField() async throws { + let field = try Field(parsing: "dueDate:timestamp:2026-02-01T09:00:00Z") + + #expect(field.name == "dueDate") + #expect(field.type == .timestamp) + #expect(field.value == "2026-02-01T09:00:00Z") + } + + @Test("Parse field with colon in value") + internal func parseFieldWithColonInValue() async throws { + let field = try Field(parsing: "url:string:https://example.com:8080") + + #expect(field.name == "url") + #expect(field.type == .string) + #expect(field.value == "https://example.com:8080") + } + + @Test("Parse field with spaces in value") + internal func parseFieldWithSpacesInValue() async throws { + let field = try Field(parsing: "description:string:This is a long description with spaces") + + #expect(field.name == "description") + #expect(field.type == .string) + #expect(field.value == "This is a long description with spaces") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldType.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldType.swift new file mode 100644 index 00000000..494d66ae --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldType.swift @@ -0,0 +1,49 @@ +// +// CreateCommandTests+FieldType.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Field Type") + internal struct FieldTypeTests { + @Test("FieldType enum has all expected cases") + internal func fieldTypeEnumCases() { + let types: [FieldType] = [.string, .int64, .double, .timestamp] + + #expect(types.count == 4) + #expect(FieldType.string.rawValue == "string") + #expect(FieldType.int64.rawValue == "int64") + #expect(FieldType.double.rawValue == "double") + #expect(FieldType.timestamp.rawValue == "timestamp") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldTypeConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldTypeConversion.swift new file mode 100644 index 00000000..84a0f2b7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldTypeConversion.swift @@ -0,0 +1,65 @@ +// +// CreateCommandTests+FieldTypeConversion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Field Type Conversion") + internal struct FieldTypeConversion { + @Test("Convert string field to CloudKit value") + internal func convertStringFieldToCloudKitValue() async throws { + let field = Field(name: "title", type: .string, value: "Test Note") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == "Test Note") + } + + @Test("Convert numeric fields to CloudKit values") + internal func convertNumericFieldsToCloudKitValues() { + let intField = Field(name: "count", type: .int64, value: "42") + let doubleField = Field(name: "percentage", type: .double, value: "0.85") + + #expect(Int(intField.value) == 42) + #expect(Double(doubleField.value) == 0.85) + } + + @Test("Convert timestamp field to CloudKit value") + internal func convertTimestampFieldToCloudKitValue() { + let field = Field(name: "createdAt", type: .timestamp, value: "2026-01-29T12:00:00Z") + let formatter = ISO8601DateFormatter() + let date = formatter.date(from: field.value) + + #expect(date != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldValidation.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldValidation.swift new file mode 100644 index 00000000..c23fb097 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+FieldValidation.swift @@ -0,0 +1,67 @@ +// +// CreateCommandTests+FieldValidation.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Field Validation") + internal struct FieldValidation { + @Test("Field parsing throws on invalid format") + internal func fieldParsingThrowsOnInvalidFormat() async throws { + #expect(throws: (any Error).self) { + _ = try Field(parsing: "invalid-format") + } + + #expect(throws: (any Error).self) { + _ = try Field(parsing: "field:missing-value") + } + + #expect(throws: (any Error).self) { + _ = try Field(parsing: "field:invalid-type:value") + } + } + + @Test("Field parsing validates field name") + internal func fieldParsingValidatesFieldName() async throws { + #expect(throws: (any Error).self) { + _ = try Field(parsing: ":string:value") + } + } + + @Test("Field parsing validates type") + internal func fieldParsingValidatesType() async throws { + #expect(throws: (any Error).self) { + _ = try Field(parsing: "field:invalidtype:value") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift new file mode 100644 index 00000000..84db2408 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift @@ -0,0 +1,80 @@ +// +// CreateCommandTests+GenerateRecordName.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("generateRecordName helper") + internal struct GenerateRecordNameHelper { + @Test("generateRecordName prefixes with lowercased record type") + internal func lowercasePrefix() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig, recordType: "Article") + let command = CreateCommand(config: config) + + let name = command.generateRecordName() + + #expect(name.hasPrefix("article-")) + } + + @Test("generateRecordName format is --<4-digit suffix>") + internal func threePartFormat() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig, recordType: "Note") + let command = CreateCommand(config: config) + + let name = command.generateRecordName() + let parts = name.split(separator: "-").map(String.init) + + #expect(parts.count == 3) + #expect(parts[0] == "note") + #expect(Int(parts[1]) != nil, "expected a unix timestamp; got \(parts[1])") + let suffix = try #require(Int(parts[2])) + #expect(suffix >= MistDemoConstants.Limits.randomSuffixMin) + #expect(suffix <= MistDemoConstants.Limits.randomSuffixMax) + } + + @Test("generateRecordName produces distinct values across many calls") + internal func distinctness() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig, recordType: "Note") + let command = CreateCommand(config: config) + + // The random suffix has ~9000 values; 200 samples should be highly unique. + // Allow some collisions but require that most samples are distinct, which + // verifies the random component is being used. + let names = (0..<200).map { _ in command.generateRecordName() } + let unique = Set(names) + #expect(unique.count > 150) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+JSONFieldLoading.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+JSONFieldLoading.swift new file mode 100644 index 00000000..0b575a2e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+JSONFieldLoading.swift @@ -0,0 +1,96 @@ +// +// CreateCommandTests+JSONFieldLoading.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("JSON Field Loading") + internal struct JSONFieldLoading { + @Test("Load fields from JSON dictionary") + internal func loadFieldsFromJSONDictionary() async throws { + let json = """ + { + "title": "Test Note", + "priority": 5, + "progress": 0.75, + "isComplete": true, + "tags": ["work", "important"] + } + """ + + let data = Data(json.utf8) + let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + #expect(dictionary != nil) + #expect(dictionary?["title"] as? String == "Test Note") + #expect(dictionary?["priority"] as? Int == 5) + #expect(dictionary?["progress"] as? Double == 0.75) + } + + @Test("Convert JSON values to Field objects") + internal func convertJSONValuesToFields() { + let jsonValues: [String: Any] = [ + "title": "Test Note", + "priority": 5, + "progress": 0.75, + "createdAt": "2026-01-29T12:00:00Z", + ] + + var fields: [Field] = [] + + for (key, value) in jsonValues { + let field: Field + switch value { + case let stringValue as String: + if stringValue.contains("T") && stringValue.contains("Z") { + field = Field(name: key, type: .timestamp, value: stringValue) + } else { + field = Field(name: key, type: .string, value: stringValue) + } + case let intValue as Int: + field = Field(name: key, type: .int64, value: String(intValue)) + case let doubleValue as Double: + field = Field(name: key, type: .double, value: String(doubleValue)) + default: + field = Field(name: key, type: .string, value: String(describing: value)) + } + fields.append(field) + } + + #expect(fields.count == 4) + #expect(fields.contains { $0.name == "title" && $0.type == .string }) + #expect(fields.contains { $0.name == "priority" && $0.type == .int64 }) + #expect(fields.contains { $0.name == "progress" && $0.type == .double }) + #expect(fields.contains { $0.name == "createdAt" && $0.type == .timestamp }) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+MultipleFieldParsing.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+MultipleFieldParsing.swift new file mode 100644 index 00000000..0a0858e4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+MultipleFieldParsing.swift @@ -0,0 +1,51 @@ +// +// CreateCommandTests+MultipleFieldParsing.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Multiple Field Parsing") + internal struct MultipleFieldParsing { + @Test("Parse multiple fields from comma-separated string") + internal func parseMultipleFieldsFromString() async throws { + let fieldsString = "title:string:Test Note, priority:int64:5, progress:double:0.5" + let fields = try fieldsString.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .map { try Field(parsing: String($0)) } + + #expect(fields.count == 3) + #expect(fields[0].name == "title") + #expect(fields[1].name == "priority") + #expect(fields[2].name == "progress") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+RecordNameGeneration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+RecordNameGeneration.swift new file mode 100644 index 00000000..f441cf3f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+RecordNameGeneration.swift @@ -0,0 +1,61 @@ +// +// CreateCommandTests+RecordNameGeneration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("Record Name Generation") + internal struct RecordNameGeneration { + @Test("Generate record name when not provided") + internal func generateRecordNameWhenNotProvided() { + let uuid = UUID().uuidString + let recordName = "Note-\(uuid)" + + #expect(recordName.hasPrefix("Note-")) + #expect(recordName.count > 5) + } + + @Test("Use provided record name") + internal func useProvidedRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + zone: "_defaultZone", + recordName: "customRecordName", + fields: [] + ) + + #expect(config.recordName == "customRecordName") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests.swift new file mode 100644 index 00000000..3ebf47ff --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests.swift @@ -0,0 +1,33 @@ +// +// CreateCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CreateCommand") +internal enum CreateCommandTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommandTests.swift deleted file mode 100644 index e4f5a1e6..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommandTests.swift +++ /dev/null @@ -1,336 +0,0 @@ -// -// CreateCommandTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("CreateCommand Tests") -struct CreateCommandTests { - // MARK: - Configuration Tests - - @Test("CreateConfig initializes with default values") - func createConfigInitializesWithDefaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - zone: "_defaultZone", - recordName: nil, - fields: [] - ) - - #expect(config.zone == "_defaultZone") - #expect(config.recordName == nil) - #expect(config.fields.isEmpty) - } - - @Test("CreateConfig accepts custom values") - func createConfigAcceptsCustomValues() async throws { - let baseConfig = try await MistDemoConfig() - let fields = [ - Field(name: "title", type: .string, value: "Test Note"), - Field(name: "priority", type: .int64, value: "5") - ] - let config = CreateConfig( - base: baseConfig, - zone: "customZone", - recordName: "customRecord", - fields: fields - ) - - #expect(config.zone == "customZone") - #expect(config.recordName == "customRecord") - #expect(config.fields.count == 2) - } - - // MARK: - Command Property Tests - - @Test("Command has correct static properties") - func commandHasCorrectStaticProperties() { - #expect(CreateCommand.commandName == "create") - #expect(CreateCommand.abstract == "Create a new record in CloudKit") - } - - @Test("Command initializes with config") - func commandInitializesWithConfig() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - zone: "_defaultZone", - recordName: nil, - fields: [] - ) - let _ = CreateCommand(config: config) - - #expect(CreateCommand.commandName == "create") - } - - // MARK: - Field Type Tests - - @Test("FieldType enum has all expected cases") - func fieldTypeEnumCases() { - let types: [FieldType] = [.string, .int64, .double, .timestamp] - - #expect(types.count == 4) - #expect(FieldType.string.rawValue == "string") - #expect(FieldType.int64.rawValue == "int64") - #expect(FieldType.double.rawValue == "double") - #expect(FieldType.timestamp.rawValue == "timestamp") - } - - // MARK: - Field Parsing Tests - - @Test("Parse string field") - func parseStringField() async throws { - let field = try Field(parsing:"title:string:My Note") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "My Note") - } - - @Test("Parse int64 field") - func parseInt64Field() async throws { - let field = try Field(parsing:"priority:int64:5") - - #expect(field.name == "priority") - #expect(field.type == .int64) - #expect(field.value == "5") - } - - @Test("Parse double field") - func parseDoubleField() async throws { - let field = try Field(parsing:"progress:double:0.75") - - #expect(field.name == "progress") - #expect(field.type == .double) - #expect(field.value == "0.75") - } - - @Test("Parse timestamp field") - func parseTimestampField() async throws { - let field = try Field(parsing:"dueDate:timestamp:2026-02-01T09:00:00Z") - - #expect(field.name == "dueDate") - #expect(field.type == .timestamp) - #expect(field.value == "2026-02-01T09:00:00Z") - } - - @Test("Parse field with colon in value") - func parseFieldWithColonInValue() async throws { - let field = try Field(parsing:"url:string:https://example.com:8080") - - #expect(field.name == "url") - #expect(field.type == .string) - #expect(field.value == "https://example.com:8080") - } - - @Test("Parse field with spaces in value") - func parseFieldWithSpacesInValue() async throws { - let field = try Field(parsing:"description:string:This is a long description with spaces") - - #expect(field.name == "description") - #expect(field.type == .string) - #expect(field.value == "This is a long description with spaces") - } - - // MARK: - Field Validation Tests - - @Test("Field parsing throws on invalid format") - func fieldParsingThrowsOnInvalidFormat() async throws { - #expect(throws: Error.self) { - _ = try Field(parsing:"invalid-format") - } - - #expect(throws: Error.self) { - _ = try Field(parsing:"field:missing-value") - } - - #expect(throws: Error.self) { - _ = try Field(parsing:"field:invalid-type:value") - } - } - - @Test("Field parsing validates field name") - func fieldParsingValidatesFieldName() async throws { - #expect(throws: Error.self) { - _ = try Field(parsing:":string:value") - } - } - - @Test("Field parsing validates type") - func fieldParsingValidatesType() async throws { - #expect(throws: Error.self) { - _ = try Field(parsing:"field:invalidtype:value") - } - } - - // MARK: - Multiple Field Parsing Tests - - @Test("Parse multiple fields from comma-separated string") - func parseMultipleFieldsFromString() async throws { - let fieldsString = "title:string:Test Note, priority:int64:5, progress:double:0.5" - let fields = try fieldsString.split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .map { try Field(parsing: String($0)) } - - #expect(fields.count == 3) - #expect(fields[0].name == "title") - #expect(fields[1].name == "priority") - #expect(fields[2].name == "progress") - } - - // MARK: - JSON Field Loading Tests - - @Test("Load fields from JSON dictionary") - func loadFieldsFromJSONDictionary() async throws { - let json = """ - { - "title": "Test Note", - "priority": 5, - "progress": 0.75, - "isComplete": true, - "tags": ["work", "important"] - } - """ - - let data = Data(json.utf8) - let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - #expect(dictionary != nil) - #expect(dictionary?["title"] as? String == "Test Note") - #expect(dictionary?["priority"] as? Int == 5) - #expect(dictionary?["progress"] as? Double == 0.75) - } - - @Test("Convert JSON values to Field objects") - func convertJSONValuesToFields() { - let jsonValues: [String: Any] = [ - "title": "Test Note", - "priority": 5, - "progress": 0.75, - "createdAt": "2026-01-29T12:00:00Z" - ] - - var fields: [Field] = [] - - for (key, value) in jsonValues { - let field: Field - switch value { - case let stringValue as String: - if stringValue.contains("T") && stringValue.contains("Z") { - field = Field(name: key, type: .timestamp, value: stringValue) - } else { - field = Field(name: key, type: .string, value: stringValue) - } - case let intValue as Int: - field = Field(name: key, type: .int64, value: String(intValue)) - case let doubleValue as Double: - field = Field(name: key, type: .double, value: String(doubleValue)) - default: - field = Field(name: key, type: .string, value: String(describing: value)) - } - fields.append(field) - } - - #expect(fields.count == 4) - #expect(fields.contains { $0.name == "title" && $0.type == .string }) - #expect(fields.contains { $0.name == "priority" && $0.type == .int64 }) - #expect(fields.contains { $0.name == "progress" && $0.type == .double }) - #expect(fields.contains { $0.name == "createdAt" && $0.type == .timestamp }) - } - - // MARK: - Record Name Generation Tests - - @Test("Generate record name when not provided") - func generateRecordNameWhenNotProvided() { - let uuid = UUID().uuidString - let recordName = "Note-\(uuid)" - - #expect(recordName.hasPrefix("Note-")) - #expect(recordName.count > 5) - } - - @Test("Use provided record name") - func useProvidedRecordName() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - zone: "_defaultZone", - recordName: "customRecordName", - fields: [] - ) - - #expect(config.recordName == "customRecordName") - } - - // MARK: - Field Type Conversion Tests - - @Test("Convert string field to CloudKit value") - func convertStringFieldToCloudKitValue() async throws { - let field = Field(name: "title", type: .string, value: "Test Note") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "Test Note") - } - - @Test("Convert numeric fields to CloudKit values") - func convertNumericFieldsToCloudKitValues() { - let intField = Field(name: "count", type: .int64, value: "42") - let doubleField = Field(name: "percentage", type: .double, value: "0.85") - - #expect(Int(intField.value) == 42) - #expect(Double(doubleField.value) == 0.85) - } - - @Test("Convert timestamp field to CloudKit value") - func convertTimestampFieldToCloudKitValue() { - let field = Field(name: "createdAt", type: .timestamp, value: "2026-01-29T12:00:00Z") - let formatter = ISO8601DateFormatter() - let date = formatter.date(from: field.value) - - #expect(date != nil) - } - - // MARK: - Error Handling Tests - - @Test("CreateError cases") - func createErrorCases() { - let parseError = CreateError.invalidJSONFormat("Invalid JSON format") - let fileError = CreateError.jsonFileError("test.json", "File not found") - let conversionError = CreateError.fieldConversionError("field", .string, "value", "Conversion failed") - - #expect(parseError.errorDescription?.contains("Invalid JSON format") == true) - #expect(fileError.errorDescription?.contains("File not found") == true) - #expect(conversionError.errorDescription?.contains("Conversion failed") == true) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+CommandProperty.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+CommandProperty.swift new file mode 100644 index 00000000..86862304 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+CommandProperty.swift @@ -0,0 +1,55 @@ +// +// CurrentUserCommandTests+CommandProperty.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Command Property") + internal struct CommandProperty { + @Test("Command has correct static properties") + internal func commandHasCorrectStaticProperties() { + #expect(CurrentUserCommand.commandName == "current-user") + #expect(CurrentUserCommand.abstract == "Get current user information") + } + + @Test("Command initializes with config") + internal func commandInitializesWithConfig() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig(base: baseConfig) + _ = CurrentUserCommand(config: config) + + // Command should be created successfully + #expect(CurrentUserCommand.commandName == "current-user") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+Configuration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+Configuration.swift new file mode 100644 index 00000000..0a9546bd --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+Configuration.swift @@ -0,0 +1,62 @@ +// +// CurrentUserCommandTests+Configuration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Configuration") + internal struct Configuration { + @Test("CurrentUserConfig initializes with default values") + internal func currentUserConfigInitializesWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig(base: baseConfig) + + #expect(config.fields == nil) + #expect(config.output == .json) + } + + @Test("CurrentUserConfig accepts custom values") + internal func currentUserConfigAcceptsCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let fields = ["userRecordName", "emailAddress"] + let config = CurrentUserConfig( + base: baseConfig, + fields: fields, + output: .table + ) + + #expect(config.fields == fields) + #expect(config.output == .table) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+DatabaseSelection.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+DatabaseSelection.swift new file mode 100644 index 00000000..e94be396 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+DatabaseSelection.swift @@ -0,0 +1,49 @@ +// +// CurrentUserCommandTests+DatabaseSelection.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Database Selection") + internal struct DatabaseSelection { + @Test("Database defaults to private for authenticated user") + internal func databaseDefaultsToPrivate() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig(base: baseConfig) + + // With web auth token, database should be private + // This is determined during command execution based on auth + #expect(config.base.environment == .development) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+ErrorHandling.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+ErrorHandling.swift new file mode 100644 index 00000000..9d4270ef --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+ErrorHandling.swift @@ -0,0 +1,62 @@ +// +// CurrentUserCommandTests+ErrorHandling.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Error Handling") + internal struct ErrorHandling { + @Test("Command handles authentication error gracefully") + internal func commandHandlesAuthError() async throws { + // Test that authentication errors are properly handled + let error = MistDemoError.authenticationFailed( + description: "Invalid credentials", + context: "current-user" + ) + + #expect(error.errorCode == "AUTHENTICATION_FAILED") + #expect(error.errorDescription?.contains("current-user") == true) + #expect(error.recoverySuggestion != nil) + } + + @Test("Command handles missing API token") + internal func commandHandlesMissingAPIToken() async throws { + // Test configuration error for missing API token + let error = ConfigurationError.missingRequired( + "api.token", + suggestion: "Provide API token via --api-token or environment variable" + ) + + #expect(error.errorDescription?.contains("api.token") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+FieldFiltering.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+FieldFiltering.swift new file mode 100644 index 00000000..f4faa0cf --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+FieldFiltering.swift @@ -0,0 +1,60 @@ +// +// CurrentUserCommandTests+FieldFiltering.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Field Filtering") + internal struct FieldFiltering { + @Test("Field filtering with nil fields returns all") + internal func fieldFilteringWithNilFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig(base: baseConfig, fields: nil) + + // When fields is nil, all fields should be included + #expect(config.fields == nil) + } + + @Test("Field filtering with specific fields") + internal func fieldFilteringWithSpecificFields() async throws { + let baseConfig = try await MistDemoConfig() + let fields = ["userRecordName", "emailAddress", "firstName"] + let config = CurrentUserConfig(base: baseConfig, fields: fields) + + #expect(config.fields?.count == 3) + #expect(config.fields?.contains("userRecordName") == true) + #expect(config.fields?.contains("emailAddress") == true) + #expect(config.fields?.contains("firstName") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+MistKitClientFactoryIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+MistKitClientFactoryIntegration.swift new file mode 100644 index 00000000..4ec14d02 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+MistKitClientFactoryIntegration.swift @@ -0,0 +1,48 @@ +// +// CurrentUserCommandTests+MistKitClientFactoryIntegration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("MistKitClientFactory Integration") + internal struct MistKitClientFactoryIntegration { + @Test("MistKitClientFactory configuration") + internal func mistKitClientFactoryConfig() async throws { + let config = try await MistDemoConfig() + + // Verify config has necessary properties for client creation + #expect(config.containerIdentifier == "iCloud.com.brightdigit.MistDemo") + #expect(config.environment == .development) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+MockUserResponse.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+MockUserResponse.swift new file mode 100644 index 00000000..f0374f62 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+MockUserResponse.swift @@ -0,0 +1,56 @@ +// +// CurrentUserCommandTests+MockUserResponse.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Mock User Response") + internal struct MockUserResponse { + @Test("Mock user response structure") + internal func mockUserResponseStructure() { + // This test verifies the expected structure of a user response + let mockUser: [String: Any] = [ + "userRecordName": "_abc123def456", + "emailAddress": "test@example.com", + "firstName": "Test", + "lastName": "User", + "hasValidatedEmail": true, + ] + + #expect(mockUser["userRecordName"] as? String == "_abc123def456") + #expect(mockUser["emailAddress"] as? String == "test@example.com") + #expect(mockUser["firstName"] as? String == "Test") + #expect(mockUser["lastName"] as? String == "User") + #expect(mockUser["hasValidatedEmail"] as? Bool == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+OutputFormat.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+OutputFormat.swift new file mode 100644 index 00000000..8cd67cd9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests+OutputFormat.swift @@ -0,0 +1,60 @@ +// +// CurrentUserCommandTests+OutputFormat.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CurrentUserCommandTests { + @Suite("Output Format") + internal struct OutputFormatTests { + @Test("Output format enum has all expected cases") + internal func outputFormatEnumCases() { + let formats: [OutputFormat] = [.json, .table, .csv, .yaml] + + #expect(formats.count == 4) + #expect(OutputFormat.json.rawValue == "json") + #expect(OutputFormat.table.rawValue == "table") + #expect(OutputFormat.csv.rawValue == "csv") + #expect(OutputFormat.yaml.rawValue == "yaml") + } + + @Test("Output format is case iterable") + internal func outputFormatIsCaseIterable() { + let allCases = OutputFormat.allCases + + #expect(allCases.count == 4) + #expect(allCases.contains(.json)) + #expect(allCases.contains(.table)) + #expect(allCases.contains(.csv)) + #expect(allCases.contains(.yaml)) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests.swift new file mode 100644 index 00000000..200af07e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommand/CurrentUserCommandTests.swift @@ -0,0 +1,33 @@ +// +// CurrentUserCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CurrentUserCommand") +internal enum CurrentUserCommandTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommandTests.swift deleted file mode 100644 index eaaf4eea..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/CurrentUserCommandTests.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// CurrentUserCommandTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("CurrentUserCommand Tests") -struct CurrentUserCommandTests { - // MARK: - Configuration Tests - - @Test("CurrentUserConfig initializes with default values") - func currentUserConfigInitializesWithDefaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig(base: baseConfig) - - #expect(config.fields == nil) - #expect(config.output == .json) - } - - @Test("CurrentUserConfig accepts custom values") - func currentUserConfigAcceptsCustomValues() async throws { - let baseConfig = try await MistDemoConfig() - let fields = ["userRecordName", "emailAddress"] - let config = CurrentUserConfig( - base: baseConfig, - fields: fields, - output: .table - ) - - #expect(config.fields == fields) - #expect(config.output == .table) - } - - // MARK: - Command Property Tests - - @Test("Command has correct static properties") - func commandHasCorrectStaticProperties() { - #expect(CurrentUserCommand.commandName == "current-user") - #expect(CurrentUserCommand.abstract == "Get current user information") - } - - @Test("Command initializes with config") - func commandInitializesWithConfig() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig(base: baseConfig) - let _ = CurrentUserCommand(config: config) - - // Command should be created successfully - #expect(CurrentUserCommand.commandName == "current-user") - } - - // MARK: - Output Format Tests - - @Test("Output format enum has all expected cases") - func outputFormatEnumCases() { - let formats: [OutputFormat] = [.json, .table, .csv, .yaml] - - #expect(formats.count == 4) - #expect(OutputFormat.json.rawValue == "json") - #expect(OutputFormat.table.rawValue == "table") - #expect(OutputFormat.csv.rawValue == "csv") - #expect(OutputFormat.yaml.rawValue == "yaml") - } - - @Test("Output format is case iterable") - func outputFormatIsCaseIterable() { - let allCases = OutputFormat.allCases - - #expect(allCases.count == 4) - #expect(allCases.contains(.json)) - #expect(allCases.contains(.table)) - #expect(allCases.contains(.csv)) - #expect(allCases.contains(.yaml)) - } - - // MARK: - Field Filtering Tests - - @Test("Field filtering with nil fields returns all") - func fieldFilteringWithNilFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig(base: baseConfig, fields: nil) - - // When fields is nil, all fields should be included - #expect(config.fields == nil) - } - - @Test("Field filtering with specific fields") - func fieldFilteringWithSpecificFields() async throws { - let baseConfig = try await MistDemoConfig() - let fields = ["userRecordName", "emailAddress", "firstName"] - let config = CurrentUserConfig(base: baseConfig, fields: fields) - - #expect(config.fields?.count == 3) - #expect(config.fields?.contains("userRecordName") == true) - #expect(config.fields?.contains("emailAddress") == true) - #expect(config.fields?.contains("firstName") == true) - } - - // MARK: - Mock User Response Tests - - @Test("Mock user response structure") - func mockUserResponseStructure() { - // This test verifies the expected structure of a user response - let mockUser: [String: Any] = [ - "userRecordName": "_abc123def456", - "emailAddress": "test@example.com", - "firstName": "Test", - "lastName": "User", - "hasValidatedEmail": true - ] - - #expect(mockUser["userRecordName"] as? String == "_abc123def456") - #expect(mockUser["emailAddress"] as? String == "test@example.com") - #expect(mockUser["firstName"] as? String == "Test") - #expect(mockUser["lastName"] as? String == "User") - #expect(mockUser["hasValidatedEmail"] as? Bool == true) - } - - // MARK: - Error Handling Tests - - @Test("Command handles authentication error gracefully") - func commandHandlesAuthError() async throws { - // Test that authentication errors are properly handled - let error = MistDemoError.authenticationFailed( - description: "Invalid credentials", - context: "current-user" - ) - - #expect(error.errorCode == "AUTHENTICATION_FAILED") - #expect(error.errorDescription?.contains("current-user") == true) - #expect(error.recoverySuggestion != nil) - } - - @Test("Command handles missing API token") - func commandHandlesMissingAPIToken() async throws { - // Test configuration error for missing API token - let error = ConfigurationError.missingRequired( - "api.token", - suggestion: "Provide API token via --api-token or environment variable" - ) - - #expect(error.errorDescription?.contains("api.token") == true) - } - - // MARK: - Database Selection Tests - - @Test("Database defaults to private for authenticated user") - func databaseDefaultsToPrivate() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig(base: baseConfig) - - // With web auth token, database should be private - // This is determined during command execution based on auth - #expect(config.base.environment == .development) - } - - // MARK: - Integration with MistKitClientFactory - - @Test("MistKitClientFactory configuration") - func mistKitClientFactoryConfig() async throws { - let config = try await MistDemoConfig() - - // Verify config has necessary properties for client creation - #expect(config.containerIdentifier == "iCloud.com.brightdigit.MistDemo") - #expect(config.environment == .development) - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift new file mode 100644 index 00000000..a783ca96 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift @@ -0,0 +1,85 @@ +// +// DeleteCommandMapConflictTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import MistKit +import Testing + +@testable import MistDemoKit + +@Suite("DeleteCommand.mapConflict Tests") +internal struct DeleteCommandMapConflictTests { + @Test("Maps httpError 409 to .conflict with nil reason") + internal func httpError409() { + let result = DeleteCommand.mapConflict(.httpError(statusCode: 409)) + guard case .conflict(let reason) = result else { + Issue.record("Expected .conflict, got \(String(describing: result))") + return + } + #expect(reason == nil) + } + + @Test("Maps httpErrorWithDetails 409 to .conflict including the reason") + internal func httpErrorWithDetails409() { + let result = DeleteCommand.mapConflict( + .httpErrorWithDetails( + statusCode: 409, serverErrorCode: "ATOMIC_ERROR", reason: "Change tag mismatch" + ) + ) + guard case .conflict(let reason) = result else { + Issue.record("Expected .conflict, got \(String(describing: result))") + return + } + #expect(reason == "Change tag mismatch") + } + + @Test("Maps httpErrorWithRawResponse 409 to .conflict with nil reason") + internal func httpErrorWithRawResponse409() { + let result = DeleteCommand.mapConflict( + .httpErrorWithRawResponse(statusCode: 409, rawResponse: "...") + ) + guard case .conflict(let reason) = result else { + Issue.record("Expected .conflict, got \(String(describing: result))") + return + } + #expect(reason == nil) + } + + @Test( + "Non-409 HTTP errors do not map to .conflict", + arguments: [400, 401, 403, 404, 500, 503] + ) + internal func nonConflictHTTPCodes(statusCode: Int) { + #expect(DeleteCommand.mapConflict(.httpError(statusCode: statusCode)) == nil) + } + + @Test("Non-HTTP CloudKitErrors do not map to .conflict") + internal func nonHTTPErrors() { + #expect(DeleteCommand.mapConflict(.invalidResponse) == nil) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandTests.swift new file mode 100644 index 00000000..770dee1a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandTests.swift @@ -0,0 +1,49 @@ +// +// DeleteCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("DeleteCommand Tests") +internal struct DeleteCommandTests { + @Test("Command has correct static properties") + internal func staticProperties() { + #expect(DeleteCommand.commandName == "delete") + #expect(DeleteCommand.abstract == "Delete an existing record from CloudKit") + #expect(DeleteCommand.helpText.contains("DELETE")) + } + + @Test("Command initializes with config") + internal func initializesWithConfig() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig(base: baseConfig, recordName: "rec-1") + _ = DeleteCommand(config: config) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift new file mode 100644 index 00000000..fc4945ef --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift @@ -0,0 +1,70 @@ +// +// DemoErrorsRunnerOutputTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("DemoErrorsRunner output helpers") +internal struct DemoErrorsRunnerOutputTests { + @Test("describe(nil) returns the placeholder") + internal func describeNil() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + #expect(runner.describe(nil) == "") + } + + @Test("describe(\"\") returns the placeholder") + internal func describeEmpty() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + #expect(runner.describe("") == "") + } + + @Test("describe echoes a non-empty tag verbatim") + internal func describeNonEmpty() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + #expect(runner.describe("rec-tag-1") == "rec-tag-1") + } + + @Test("describe preserves whitespace in a non-empty tag") + internal func describePreservesWhitespace() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + // Only fully empty strings are normalized to ; + // whitespace-only tags are kept as-is. + #expect(runner.describe(" ") == " ") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/LookupCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/LookupCommandTests.swift new file mode 100644 index 00000000..b758d588 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/LookupCommandTests.swift @@ -0,0 +1,55 @@ +// +// LookupCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("LookupCommand Tests") +internal struct LookupCommandTests { + @Test("Command has correct static properties") + internal func staticProperties() { + #expect(LookupCommand.commandName == "lookup") + #expect(LookupCommand.abstract == "Look up records by name from CloudKit") + #expect(LookupCommand.helpText.contains("LOOKUP")) + } + + @Test("Command initializes with config") + internal func initializesWithConfig() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig(base: baseConfig, recordNames: ["rec-1"]) + _ = LookupCommand(config: config) + } + + @Test("Command help text documents that missing records go to stderr") + internal func helpTextMentionsStderr() { + #expect(LookupCommand.helpText.contains("stderr")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyCommandTests.swift new file mode 100644 index 00000000..e6d1f5d6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyCommandTests.swift @@ -0,0 +1,50 @@ +// +// ModifyCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("ModifyCommand Tests") +internal struct ModifyCommandTests { + @Test("Command has correct static properties") + internal func staticProperties() { + #expect(ModifyCommand.commandName == "modify") + #expect(ModifyCommand.abstract == "Run a batch of create/update/delete operations") + #expect(ModifyCommand.helpText.contains("MODIFY")) + } + + @Test("Command initializes with config") + internal func initializesWithConfig() async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig(base: baseConfig, operations: []) + _ = ModifyCommand(config: config) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyOutputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyOutputTests.swift new file mode 100644 index 00000000..633a2fe8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyOutputTests.swift @@ -0,0 +1,91 @@ +// +// ModifyOutputTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("ModifyOutput Tests") +internal struct ModifyOutputTests { + @Test("ModifyOutput JSON envelope carries partialFailure metadata") + internal func envelopeIncludesMetadata() throws { + let row = ModifyResultRow( + operation: "applied", recordType: "Note", recordName: "n-1", recordChangeTag: "t-1" + ) + let envelope = ModifyOutput( + results: [row], + attempted: 3, + succeeded: 1, + partialFailure: true + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains("\"attempted\":3")) + #expect(json.contains("\"succeeded\":1")) + #expect(json.contains("\"partialFailure\":true")) + #expect(json.contains("\"results\":[")) + } + + @Test("ModifyOutput partialFailure=false when all ops succeed") + internal func noPartialFailureWhenAllSucceed() throws { + let row = ModifyResultRow( + operation: "applied", recordType: "Note", recordName: "n-1", recordChangeTag: "t-1" + ) + let envelope = ModifyOutput( + results: [row], + attempted: 1, + succeeded: 1, + partialFailure: false + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains("\"partialFailure\":false")) + } + + @Test("ModifyOutput with delete-only batch and zero record results is not a partial failure") + internal func deleteOnlyBatchNotPartialFailure() throws { + // Delete operations succeed without returning a record. A delete-only + // batch where the response has zero records is a complete success, + // not a partial failure — the envelope reflects that. + let envelope = ModifyOutput( + results: [], + attempted: 3, + succeeded: 0, + partialFailure: false + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains("\"partialFailure\":false")) + #expect(json.contains("\"attempted\":3")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyResultRowTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyResultRowTests.swift new file mode 100644 index 00000000..3931642f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/ModifyResultRowTests.swift @@ -0,0 +1,53 @@ +// +// ModifyResultRowTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("ModifyResultRow Tests") +internal struct ModifyResultRowTests { + @Test("ModifyResultRow encodes all fields") + internal func encodesFields() throws { + let row = ModifyResultRow( + operation: "applied", + recordType: "Note", + recordName: "note-1", + recordChangeTag: "tag-xyz" + ) + let data = try JSONEncoder().encode(row) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains("\"op\":\"applied\"")) + #expect(json.contains("\"recordType\":\"Note\"")) + #expect(json.contains("\"recordName\":\"note-1\"")) + #expect(json.contains("\"recordChangeTag\":\"tag-xyz\"")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+CommandProperty.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+CommandProperty.swift new file mode 100644 index 00000000..48b4788c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+CommandProperty.swift @@ -0,0 +1,54 @@ +// +// QueryCommandTests+CommandProperty.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Command Property") + internal struct CommandProperty { + @Test("Command has correct static properties") + internal func commandHasCorrectStaticProperties() { + #expect(QueryCommand.commandName == "query") + #expect(QueryCommand.abstract == "Query records from CloudKit with filtering and sorting") + } + + @Test("Command initializes with config") + internal func commandInitializesWithConfig() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + _ = QueryCommand(config: config) + + #expect(QueryCommand.commandName == "query") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+Configuration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+Configuration.swift new file mode 100644 index 00000000..1afa8400 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+Configuration.swift @@ -0,0 +1,83 @@ +// +// QueryCommandTests+Configuration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Configuration") + internal struct Configuration { + @Test("QueryConfig initializes with default values") + internal func queryConfigInitializesWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Note") + #expect(config.filters.isEmpty) + #expect(config.sort == nil) + #expect(config.limit == 20) + #expect(config.offset == 0) + #expect(config.fields == nil) + #expect(config.continuationMarker == nil) + #expect(config.output == .json) + } + + @Test("QueryConfig accepts custom values") + internal func queryConfigAcceptsCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "customZone", + recordType: "CustomType", + filters: ["title:eq:Test"], + sort: (field: "createdAt", order: .descending), + limit: 50, + offset: 10, + fields: ["title", "content"], + continuationMarker: "marker123", + output: .table + ) + + #expect(config.zone == "customZone") + #expect(config.recordType == "CustomType") + #expect(config.filters == ["title:eq:Test"]) + #expect(config.sort?.field == "createdAt") + #expect(config.sort?.order == .descending) + #expect(config.limit == 50) + #expect(config.offset == 10) + #expect(config.fields == ["title", "content"]) + #expect(config.continuationMarker == "marker123") + #expect(config.output == .table) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ContinuationMarker.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ContinuationMarker.swift new file mode 100644 index 00000000..a5273e77 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ContinuationMarker.swift @@ -0,0 +1,58 @@ +// +// QueryCommandTests+ContinuationMarker.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Continuation Marker") + internal struct ContinuationMarker { + @Test("Continuation marker for pagination") + internal func continuationMarkerForPagination() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + continuationMarker: "next-page-marker" + ) + + #expect(config.continuationMarker == "next-page-marker") + } + + @Test("No continuation marker for first page") + internal func noContinuationMarkerForFirstPage() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.continuationMarker == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+FieldSelection.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+FieldSelection.swift new file mode 100644 index 00000000..34cbcf29 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+FieldSelection.swift @@ -0,0 +1,59 @@ +// +// QueryCommandTests+FieldSelection.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Field Selection") + internal struct FieldSelection { + @Test("Field selection with nil returns all fields") + internal func fieldSelectionNilReturnsAll() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig, fields: nil) + + #expect(config.fields == nil) + } + + @Test("Field selection with specific fields") + internal func fieldSelectionWithSpecificFields() async throws { + let baseConfig = try await MistDemoConfig() + let fields = ["title", "content", "createdAt"] + let config = QueryConfig(base: baseConfig, fields: fields) + + #expect(config.fields?.count == 3) + #expect(config.fields?.contains("title") == true) + #expect(config.fields?.contains("content") == true) + #expect(config.fields?.contains("createdAt") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+FilterParsing.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+FilterParsing.swift new file mode 100644 index 00000000..5e4f4c6d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+FilterParsing.swift @@ -0,0 +1,71 @@ +// +// QueryCommandTests+FilterParsing.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Filter Parsing") + internal struct FilterParsing { + @Test("Parse simple filter expression") + internal func parseSimpleFilter() { + let filter = "title:eq:Test Note" + let parts = filter.split(separator: ":", maxSplits: 2).map(String.init) + + #expect(parts.count == 3) + #expect(parts[0] == "title") + #expect(parts[1] == "eq") + #expect(parts[2] == "Test Note") + } + + @Test("Parse filter with multiple colons in value") + internal func parseFilterWithColonsInValue() { + let filter = "url:eq:https://example.com:8080" + let parts = filter.split(separator: ":", maxSplits: 2).map(String.init) + + #expect(parts.count == 3) + #expect(parts[0] == "url") + #expect(parts[1] == "eq") + #expect(parts[2] == "https://example.com:8080") + } + + @Test("Filter operators are valid") + internal func filterOperatorsValid() { + let validOperators = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "contains", "beginsWith"] + + for operatorName in validOperators { + let filter = "field:\(operatorName):value" + let parts = filter.split(separator: ":").map(String.init) + #expect(validOperators.contains(parts[1])) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+LimitValidation.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+LimitValidation.swift new file mode 100644 index 00000000..4e496ca1 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+LimitValidation.swift @@ -0,0 +1,56 @@ +// +// QueryCommandTests+LimitValidation.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Limit Validation") + internal struct LimitValidation { + @Test("Limit validation accepts valid range") + internal func limitValidationAcceptsValid() { + let validLimits = [1, 50, 100, 200] + + for limit in validLimits { + #expect(limit >= 1 && limit <= 200) + } + } + + @Test("Limit validation rejects invalid values") + internal func limitValidationRejectsInvalid() { + let invalidLimits = [0, -1, 201, 500] + + for limit in invalidLimits { + #expect(!(limit >= 1 && limit <= 200)) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+MultipleFilters.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+MultipleFilters.swift new file mode 100644 index 00000000..0e02e3e4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+MultipleFilters.swift @@ -0,0 +1,55 @@ +// +// QueryCommandTests+MultipleFilters.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Multiple Filters") + internal struct MultipleFilters { + @Test("Multiple filters are preserved") + internal func multipleFiltersPreserved() async throws { + let baseConfig = try await MistDemoConfig() + let filters = [ + "title:contains:Test", + "priority:gt:5", + "status:eq:active", + ] + let config = QueryConfig(base: baseConfig, filters: filters) + + #expect(config.filters.count == 3) + #expect(config.filters[0] == "title:contains:Test") + #expect(config.filters[1] == "priority:gt:5") + #expect(config.filters[2] == "status:eq:active") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift new file mode 100644 index 00000000..8b664b51 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift @@ -0,0 +1,189 @@ +// +// QueryCommandTests+ParseFilter.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("parseFilter / inferFieldValue / shouldIncludeField") + internal struct ParseFilter { + // MARK: - inferFieldValue + + @Test("inferFieldValue parses integer literals as .int64") + internal func inferInt() { + #expect(QueryCommand.inferFieldValue("42") == .int64(42)) + #expect(QueryCommand.inferFieldValue("0") == .int64(0)) + #expect(QueryCommand.inferFieldValue("-7") == .int64(-7)) + } + + @Test("inferFieldValue parses non-integer numeric literals as .double") + internal func inferDouble() { + #expect(QueryCommand.inferFieldValue("3.14") == .double(3.14)) + #expect(QueryCommand.inferFieldValue("-2.5") == .double(-2.5)) + } + + @Test("inferFieldValue treats unparseable input as .string") + internal func inferString() { + #expect(QueryCommand.inferFieldValue("hello") == .string("hello")) + #expect(QueryCommand.inferFieldValue("12abc") == .string("12abc")) + #expect(QueryCommand.inferFieldValue("") == .string("")) + } + + // MARK: - shouldIncludeField + + @Test("shouldIncludeField returns true when filter is nil or empty") + internal func includeAllByDefault() { + #expect(QueryCommand.shouldIncludeField("title", fields: nil) == true) + #expect(QueryCommand.shouldIncludeField("title", fields: []) == true) + } + + @Test("shouldIncludeField matches case-insensitively") + internal func caseInsensitiveMatch() { + #expect(QueryCommand.shouldIncludeField("Title", fields: ["title"]) == true) + #expect(QueryCommand.shouldIncludeField("title", fields: ["TITLE"]) == true) + #expect(QueryCommand.shouldIncludeField("Body", fields: ["title", "body"]) == true) + } + + @Test("shouldIncludeField excludes fields not in filter") + internal func excludesNonMatches() { + #expect(QueryCommand.shouldIncludeField("priority", fields: ["title"]) == false) + #expect(QueryCommand.shouldIncludeField("body", fields: ["title", "priority"]) == false) + } + + // MARK: - parseFilter — happy paths + + @Test( + "parseFilter accepts comparison operators", + arguments: [ + "title:eq:hello", + "title:equals:hello", + "title:==:hello", + "title:=:hello", + "priority:ne:1", + "priority:not_equals:1", + "priority:!=:1", + "score:gt:10", + "score:>:10", + "score:gte:10", + "score:>=:10", + "score:lt:10", + "score:<:10", + "score:lte:10", + "score:<=:10", + ] + ) + internal func parsesComparisonOperators(filterString: String) throws { + _ = try QueryCommand.parseFilter(filterString) + } + + @Test( + "parseFilter accepts string and list operators", + arguments: [ + "title:contains:hello world", + "title:like:hello world", + "title:begins_with:hello", + "title:starts_with:hello", + "priority:in:1,2,3", + "priority:not_in:1,2,3", + ] + ) + internal func parsesSpecialOperators(filterString: String) throws { + _ = try QueryCommand.parseFilter(filterString) + } + + @Test("parseFilter accepts operator names in any case") + internal func operatorCaseInsensitive() throws { + _ = try QueryCommand.parseFilter("title:EQ:hello") + _ = try QueryCommand.parseFilter("title:Equals:hello") + _ = try QueryCommand.parseFilter("title:BEGINS_WITH:hello") + } + + @Test("parseFilter preserves colons in value (maxSplits=2)") + internal func valueWithColons() throws { + _ = try QueryCommand.parseFilter("url:eq:https://example.com:8080/path") + } + + // MARK: - parseFilter — error paths + + @Test("parseFilter throws invalidFilter when fewer than three components") + internal func tooFewComponentsThrows() { + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter("title:eq") + } + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter("nothing") + } + } + + @Test("parseFilter throws emptyFieldName when the field segment is blank") + internal func emptyFieldThrows() { + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter(":eq:value") + } + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter(" :eq:value") + } + } + + @Test("parseFilter throws unsupportedOperator for an unknown operator") + internal func unsupportedOperatorThrows() { + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter("title:fuzzy_match:hello") + } + } + + // MARK: - buildComparisonFilter + + @Test("buildComparisonFilter returns nil for non-comparison operators") + internal func buildComparisonFilterReturnsNilForSpecial() { + let result = QueryCommand.buildComparisonFilter( + field: "title", + operatorString: "contains", + value: "hello" + ) + #expect(result == nil) + } + + @Test( + "buildComparisonFilter returns a filter for each comparison alias", + arguments: ["eq", "ne", "gt", "gte", "lt", "lte"] + ) + internal func buildComparisonFilterReturnsNonNil(alias: String) { + let result = QueryCommand.buildComparisonFilter( + field: "score", + operatorString: alias, + value: "10" + ) + #expect(result != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+RecordType.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+RecordType.swift new file mode 100644 index 00000000..8452cf3b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+RecordType.swift @@ -0,0 +1,55 @@ +// +// QueryCommandTests+RecordType.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Record Type") + internal struct RecordTypeTests { + @Test("Default record type is Note") + internal func defaultRecordTypeIsNote() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.recordType == "Note") + } + + @Test("Custom record type is preserved") + internal func customRecordTypeIsPreserved() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig, recordType: "CustomRecord") + + #expect(config.recordType == "CustomRecord") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+SortParsing.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+SortParsing.swift new file mode 100644 index 00000000..c7b2ea7d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+SortParsing.swift @@ -0,0 +1,67 @@ +// +// QueryCommandTests+SortParsing.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Sort Parsing") + internal struct SortParsing { + @Test("Parse ascending sort") + internal func parseAscendingSort() { + let sort = "createdAt:asc" + let parts = sort.split(separator: ":").map(String.init) + + #expect(parts.count == 2) + #expect(parts[0] == "createdAt") + #expect(parts[1] == "asc") + #expect(SortOrder(rawValue: parts[1]) == .ascending) + } + + @Test("Parse descending sort") + internal func parseDescendingSort() { + let sort = "modifiedAt:desc" + let parts = sort.split(separator: ":").map(String.init) + + #expect(parts.count == 2) + #expect(parts[0] == "modifiedAt") + #expect(parts[1] == "desc") + #expect(SortOrder(rawValue: parts[1]) == .descending) + } + + @Test("SortOrder enum values") + internal func sortOrderEnumValues() { + #expect(SortOrder.ascending.rawValue == "asc") + #expect(SortOrder.descending.rawValue == "desc") + #expect(SortOrder.allCases.count == 2) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift new file mode 100644 index 00000000..00a43ec5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift @@ -0,0 +1,55 @@ +// +// QueryCommandTests+ZoneConfiguration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("Zone Configuration") + internal struct ZoneConfiguration { + @Test("Default zone is _defaultZone") + internal func defaultZoneIsDefaultZone() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.zone == "_defaultZone") + } + + @Test("Custom zone is preserved") + internal func customZoneIsPreserved() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig, zone: "customZone") + + #expect(config.zone == "customZone") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests.swift new file mode 100644 index 00000000..0dd82ea4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests.swift @@ -0,0 +1,33 @@ +// +// QueryCommandTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("QueryCommand") +internal enum QueryCommandTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommandTests.swift deleted file mode 100644 index 0daca3f6..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommandTests.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// QueryCommandTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("QueryCommand Tests") -struct QueryCommandTests { - // MARK: - Configuration Tests - - @Test("QueryConfig initializes with default values") - func queryConfigInitializesWithDefaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.zone == "_defaultZone") - #expect(config.recordType == "Note") - #expect(config.filters.isEmpty) - #expect(config.sort == nil) - #expect(config.limit == 20) - #expect(config.offset == 0) - #expect(config.fields == nil) - #expect(config.continuationMarker == nil) - #expect(config.output == .json) - } - - @Test("QueryConfig accepts custom values") - func queryConfigAcceptsCustomValues() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - zone: "customZone", - recordType: "CustomType", - filters: ["title:eq:Test"], - sort: (field: "createdAt", order: .descending), - limit: 50, - offset: 10, - fields: ["title", "content"], - continuationMarker: "marker123", - output: .table - ) - - #expect(config.zone == "customZone") - #expect(config.recordType == "CustomType") - #expect(config.filters == ["title:eq:Test"]) - #expect(config.sort?.field == "createdAt") - #expect(config.sort?.order == .descending) - #expect(config.limit == 50) - #expect(config.offset == 10) - #expect(config.fields == ["title", "content"]) - #expect(config.continuationMarker == "marker123") - #expect(config.output == .table) - } - - // MARK: - Command Property Tests - - @Test("Command has correct static properties") - func commandHasCorrectStaticProperties() { - #expect(QueryCommand.commandName == "query") - #expect(QueryCommand.abstract == "Query records from CloudKit with filtering and sorting") - } - - @Test("Command initializes with config") - func commandInitializesWithConfig() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - let _ = QueryCommand(config: config) - - #expect(QueryCommand.commandName == "query") - } - - // MARK: - Filter Parsing Tests - - @Test("Parse simple filter expression") - func parseSimpleFilter() { - let filter = "title:eq:Test Note" - let parts = filter.split(separator: ":", maxSplits: 2).map(String.init) - - #expect(parts.count == 3) - #expect(parts[0] == "title") - #expect(parts[1] == "eq") - #expect(parts[2] == "Test Note") - } - - @Test("Parse filter with multiple colons in value") - func parseFilterWithColonsInValue() { - let filter = "url:eq:https://example.com:8080" - let parts = filter.split(separator: ":", maxSplits: 2).map(String.init) - - #expect(parts.count == 3) - #expect(parts[0] == "url") - #expect(parts[1] == "eq") - #expect(parts[2] == "https://example.com:8080") - } - - @Test("Filter operators are valid") - func filterOperatorsValid() { - let validOperators = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "contains", "beginsWith"] - - for op in validOperators { - let filter = "field:\(op):value" - let parts = filter.split(separator: ":").map(String.init) - #expect(validOperators.contains(parts[1])) - } - } - - // MARK: - Sort Parsing Tests - - @Test("Parse ascending sort") - func parseAscendingSort() { - let sort = "createdAt:asc" - let parts = sort.split(separator: ":").map(String.init) - - #expect(parts.count == 2) - #expect(parts[0] == "createdAt") - #expect(parts[1] == "asc") - #expect(SortOrder(rawValue: parts[1]) == .ascending) - } - - @Test("Parse descending sort") - func parseDescendingSort() { - let sort = "modifiedAt:desc" - let parts = sort.split(separator: ":").map(String.init) - - #expect(parts.count == 2) - #expect(parts[0] == "modifiedAt") - #expect(parts[1] == "desc") - #expect(SortOrder(rawValue: parts[1]) == .descending) - } - - @Test("SortOrder enum values") - func sortOrderEnumValues() { - #expect(SortOrder.ascending.rawValue == "asc") - #expect(SortOrder.descending.rawValue == "desc") - #expect(SortOrder.allCases.count == 2) - } - - // MARK: - Limit Validation Tests - - @Test("Limit validation accepts valid range") - func limitValidationAcceptsValid() { - let validLimits = [1, 50, 100, 200] - - for limit in validLimits { - #expect(limit >= 1 && limit <= 200) - } - } - - @Test("Limit validation rejects invalid values") - func limitValidationRejectsInvalid() { - let invalidLimits = [0, -1, 201, 500] - - for limit in invalidLimits { - #expect(!(limit >= 1 && limit <= 200)) - } - } - - // MARK: - Field Selection Tests - - @Test("Field selection with nil returns all fields") - func fieldSelectionNilReturnsAll() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig, fields: nil) - - #expect(config.fields == nil) - } - - @Test("Field selection with specific fields") - func fieldSelectionWithSpecificFields() async throws { - let baseConfig = try await MistDemoConfig() - let fields = ["title", "content", "createdAt"] - let config = QueryConfig(base: baseConfig, fields: fields) - - #expect(config.fields?.count == 3) - #expect(config.fields?.contains("title") == true) - #expect(config.fields?.contains("content") == true) - #expect(config.fields?.contains("createdAt") == true) - } - - // MARK: - Continuation Marker Tests - - @Test("Continuation marker for pagination") - func continuationMarkerForPagination() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - continuationMarker: "next-page-marker" - ) - - #expect(config.continuationMarker == "next-page-marker") - } - - @Test("No continuation marker for first page") - func noContinuationMarkerForFirstPage() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.continuationMarker == nil) - } - - // MARK: - Multiple Filters Tests - - @Test("Multiple filters are preserved") - func multipleFiltersPreserved() async throws { - let baseConfig = try await MistDemoConfig() - let filters = [ - "title:contains:Test", - "priority:gt:5", - "status:eq:active" - ] - let config = QueryConfig(base: baseConfig, filters: filters) - - #expect(config.filters.count == 3) - #expect(config.filters[0] == "title:contains:Test") - #expect(config.filters[1] == "priority:gt:5") - #expect(config.filters[2] == "status:eq:active") - } - - // MARK: - Zone Configuration Tests - - @Test("Default zone is _defaultZone") - func defaultZoneIsDefaultZone() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.zone == "_defaultZone") - } - - @Test("Custom zone is preserved") - func customZoneIsPreserved() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig, zone: "customZone") - - #expect(config.zone == "customZone") - } - - // MARK: - Record Type Tests - - @Test("Default record type is Note") - func defaultRecordTypeIsNote() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.recordType == "Note") - } - - @Test("Custom record type is preserved") - func customRecordTypeIsPreserved() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig, recordType: "CustomRecord") - - #expect(config.recordType == "CustomRecord") - } -} \ No newline at end of file diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandLineParserTests.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandLineParserTests.swift new file mode 100644 index 00000000..e749446d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandLineParserTests.swift @@ -0,0 +1,77 @@ +// +// CommandLineParserTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import ConfigKeyKit + +@Suite("CommandLineParser Tests") +internal struct CommandLineParserTests { + @Test("parseCommandName returns nil when only executable name is present") + internal func noArgsReturnsNil() { + let parser = CommandLineParser(arguments: ["mistdemo"]) + + #expect(parser.parseCommandName() == nil) + #expect(parser.commandArguments().isEmpty) + #expect(parser.isHelpRequested() == false) + } + + @Test("parseCommandName returns the first non-option argument") + internal func parsesCommand() { + let parser = CommandLineParser(arguments: ["mistdemo", "query", "--limit", "10"]) + + #expect(parser.parseCommandName() == "query") + #expect(parser.commandArguments() == ["--limit", "10"]) + } + + @Test("parseCommandName returns nil when first argument is a global option") + internal func globalOptionReturnsNilCommand() { + let parser = CommandLineParser(arguments: ["mistdemo", "--config-file", "/tmp/x.json"]) + + #expect(parser.parseCommandName() == nil) + #expect(parser.commandArguments() == ["--config-file", "/tmp/x.json"]) + } + + @Test("commandArguments strips the executable + command but keeps the rest verbatim") + internal func commandArgumentsPreserveRest() { + let parser = CommandLineParser(arguments: ["mistdemo", "lookup", "rec-1", "rec-2"]) + + #expect(parser.commandArguments() == ["rec-1", "rec-2"]) + } + + @Test( + "isHelpRequested matches every documented help token", + arguments: ["--help", "-h", "help"] + ) + internal func helpTokens(token: String) { + let parser = CommandLineParser(arguments: ["mistdemo", "query", token]) + + #expect(parser.isHelpRequested() == true) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+AvailableCommands.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+AvailableCommands.swift new file mode 100644 index 00000000..91b30665 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+AvailableCommands.swift @@ -0,0 +1,73 @@ +// +// CommandRegistryTests+AvailableCommands.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Available Commands") + internal struct AvailableCommands { + @Test("Available commands lists registered commands") + internal func availableCommands() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + await registry.register(CommandRegistryTests.AnotherCommand.self) + + let commands = await registry.availableCommands + + #expect(commands.contains("test")) + #expect(commands.contains("another")) + #expect(commands.count == 2) + } + + @Test("Available commands returns empty for new registry") + internal func availableCommandsEmpty() async { + let registry = CommandRegistry() + + let commands = await registry.availableCommands + + #expect(commands.isEmpty) + } + + @Test("Available commands are sorted") + internal func availableCommandsSorted() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.AnotherCommand.self) + await registry.register(CommandRegistryTests.TestCommand.self) + + let commands = await registry.availableCommands + + #expect(commands == ["another", "test"]) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+CommandCreation.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+CommandCreation.swift new file mode 100644 index 00000000..5ae83c2a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+CommandCreation.swift @@ -0,0 +1,58 @@ +// +// CommandRegistryTests+CommandCreation.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Command Creation") + internal struct CommandCreation { + @Test("Create command instance") + internal func createCommandInstance() async throws { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + + let command = try await registry.createCommand(named: "test") + + #expect(command is CommandRegistryTests.TestCommand) + } + + @Test("Create command instance throws for unknown command") + internal func createCommandInstanceThrows() async { + let registry = CommandRegistry() + + await #expect(throws: CommandRegistryError.self) { + try await registry.createCommand(named: "unknown") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+CommandTypeRetrieval.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+CommandTypeRetrieval.swift new file mode 100644 index 00000000..310d985d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+CommandTypeRetrieval.swift @@ -0,0 +1,58 @@ +// +// CommandRegistryTests+CommandTypeRetrieval.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Command Type Retrieval") + internal struct CommandTypeRetrieval { + @Test("Get command type by name") + internal func getCommandType() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + + let commandType = await registry.commandType(named: "test") + + #expect(commandType != nil) + } + + @Test("Get command type for unregistered command") + internal func getCommandTypeUnregistered() async { + let registry = CommandRegistry() + + let commandType = await registry.commandType(named: "nonexistent") + + #expect(commandType == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+ConcurrentAccess.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+ConcurrentAccess.swift new file mode 100644 index 00000000..2dcccc11 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+ConcurrentAccess.swift @@ -0,0 +1,97 @@ +// +// CommandRegistryTests+ConcurrentAccess.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Concurrent Access") + internal struct ConcurrentAccess { + @Test("Concurrent registration") + internal func concurrentRegistration() async { + let registry = CommandRegistry() + + await withTaskGroup(of: Void.self) { group in + group.addTask { + await registry.register(CommandRegistryTests.TestCommand.self) + } + group.addTask { + await registry.register(CommandRegistryTests.AnotherCommand.self) + } + } + + let commands = await registry.availableCommands + #expect(commands.count == 2) + } + + @Test("Concurrent reads") + internal func concurrentReads() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + + await withTaskGroup(of: Bool.self) { group in + for _ in 0..<10 { + group.addTask { + await registry.isRegistered("test") + } + } + + var results: [Bool] = [] + for await result in group { + results.append(result) + } + + #expect(results.allSatisfy { $0 == true }) + } + } + + @Test("Mixed concurrent operations") + internal func mixedConcurrentOperations() async { + let registry = CommandRegistry() + + await withTaskGroup(of: Void.self) { group in + group.addTask { + await registry.register(CommandRegistryTests.TestCommand.self) + } + group.addTask { + _ = await registry.isRegistered("test") + } + group.addTask { + _ = await registry.availableCommands + } + } + + let isRegistered = await registry.isRegistered("test") + #expect(isRegistered == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Errors.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Errors.swift new file mode 100644 index 00000000..edafa285 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Errors.swift @@ -0,0 +1,54 @@ +// +// CommandRegistryTests+Errors.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Errors") + internal struct Errors { + @Test("Unknown command error has description") + internal func unknownCommandError() { + let error = CommandRegistryError.unknownCommand("missing") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Unknown command") == true) + #expect(description?.contains("missing") == true) + } + + @Test("CommandRegistryError conforms to LocalizedError") + internal func errorConformsToLocalizedError() { + let error: any Error = CommandRegistryError.unknownCommand("test") + #expect(error is any LocalizedError) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Metadata.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Metadata.swift new file mode 100644 index 00000000..256b1892 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Metadata.swift @@ -0,0 +1,61 @@ +// +// CommandRegistryTests+Metadata.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Metadata") + internal struct Metadata { + @Test("Get command metadata") + internal func getCommandMetadata() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + + let metadata = await registry.metadata(for: "test") + + #expect(metadata != nil) + #expect(metadata?.commandName == "test") + #expect(metadata?.abstract == "Test command") + #expect(metadata?.helpText == "This is a test command") + } + + @Test("Get metadata for unregistered command") + internal func getMetadataForUnregistered() async { + let registry = CommandRegistry() + + let metadata = await registry.metadata(for: "nonexistent") + + #expect(metadata == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Overwrite.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Overwrite.swift new file mode 100644 index 00000000..3e97ea51 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Overwrite.swift @@ -0,0 +1,50 @@ +// +// CommandRegistryTests+Overwrite.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Overwrite") + internal struct Overwrite { + @Test("Registering same command twice overwrites") + internal func registerCommandTwiceOverwrites() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + await registry.register(CommandRegistryTests.TestCommand.self) + + let commands = await registry.availableCommands + #expect(commands.count == 1) + #expect(commands.contains("test")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Registration.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Registration.swift new file mode 100644 index 00000000..c4970088 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+Registration.swift @@ -0,0 +1,70 @@ +// +// CommandRegistryTests+Registration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + @Suite("Registration") + internal struct Registration { + @Test("Register a command") + internal func registerCommand() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + + let isRegistered = await registry.isRegistered("test") + #expect(isRegistered == true) + } + + @Test("Register multiple commands") + internal func registerMultipleCommands() async { + let registry = CommandRegistry() + + await registry.register(CommandRegistryTests.TestCommand.self) + await registry.register(CommandRegistryTests.AnotherCommand.self) + + let testRegistered = await registry.isRegistered("test") + let anotherRegistered = await registry.isRegistered("another") + + #expect(testRegistered == true) + #expect(anotherRegistered == true) + } + + @Test("Unregistered command returns false") + internal func unregisteredCommand() async { + let registry = CommandRegistry() + + let isRegistered = await registry.isRegistered("nonexistent") + #expect(isRegistered == false) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+TestCommandTypes.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+TestCommandTypes.swift new file mode 100644 index 00000000..f0c6572a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests+TestCommandTypes.swift @@ -0,0 +1,87 @@ +// +// CommandRegistryTests+TestCommandTypes.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@testable import ConfigKeyKit + +extension CommandRegistryTests { + internal struct TestCommand: Command { + internal typealias Config = TestConfig + + internal static var commandName: String { "test" } + internal static var abstract: String { "Test command" } + internal static var helpText: String { "This is a test command" } + + internal let config: TestConfig + + internal static func createInstance() async throws -> TestCommand { + TestCommand(config: TestConfig()) + } + + internal func execute() async throws { + // No-op for testing + } + } + + internal struct AnotherCommand: Command { + internal typealias Config = TestConfig + + internal static var commandName: String { "another" } + internal static var abstract: String { "Another command" } + internal static var helpText: String { "This is another test command" } + + internal let config: TestConfig + + internal static func createInstance() async throws -> AnotherCommand { + AnotherCommand(config: TestConfig()) + } + + internal func execute() async throws { + // No-op for testing + } + } + + internal struct TestConfig: ConfigurationParseable { + internal typealias ConfigReader = TestConfigReader + internal typealias BaseConfig = Never + + internal init(configuration: TestConfigReader, base: Never? = nil) async throws { + // No-op for testing + } + + internal init() { + // Simple initializer for testing + } + } + + internal struct TestConfigReader { + // Minimal config reader for testing + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests.swift new file mode 100644 index 00000000..d119b1a9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistry/CommandRegistryTests.swift @@ -0,0 +1,33 @@ +// +// CommandRegistryTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CommandRegistry") +internal enum CommandRegistryTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistryTests.swift deleted file mode 100644 index 975c3c1d..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/ConfigKeyKit/CommandRegistryTests.swift +++ /dev/null @@ -1,332 +0,0 @@ -// -// CommandRegistryTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import ConfigKeyKit - -@Suite("CommandRegistry Tests") -struct CommandRegistryTests { - - // MARK: - Test Command Types - - struct TestCommand: Command { - typealias Config = TestConfig - - static var commandName: String { "test" } - static var abstract: String { "Test command" } - static var helpText: String { "This is a test command" } - - let config: TestConfig - - init(config: TestConfig) { - self.config = config - } - - func execute() async throws { - // No-op for testing - } - - static func createInstance() async throws -> TestCommand { - return TestCommand(config: TestConfig()) - } - } - - struct AnotherCommand: Command { - typealias Config = TestConfig - - static var commandName: String { "another" } - static var abstract: String { "Another command" } - static var helpText: String { "This is another test command" } - - let config: TestConfig - - init(config: TestConfig) { - self.config = config - } - - func execute() async throws { - // No-op for testing - } - - static func createInstance() async throws -> AnotherCommand { - return AnotherCommand(config: TestConfig()) - } - } - - struct TestConfig: ConfigurationParseable { - typealias ConfigReader = TestConfigReader - typealias BaseConfig = Never - - init(configuration: TestConfigReader, base: Never? = nil) async throws { - // No-op for testing - } - - init() { - // Simple initializer for testing - } - } - - struct TestConfigReader { - // Minimal config reader for testing - } - - // MARK: - Registration Tests - - @Test("Register a command") - func registerCommand() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - - let isRegistered = await registry.isRegistered("test") - #expect(isRegistered == true) - } - - @Test("Register multiple commands") - func registerMultipleCommands() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - await registry.register(AnotherCommand.self) - - let testRegistered = await registry.isRegistered("test") - let anotherRegistered = await registry.isRegistered("another") - - #expect(testRegistered == true) - #expect(anotherRegistered == true) - } - - @Test("Unregistered command returns false") - func unregisteredCommand() async { - let registry = CommandRegistry() - - let isRegistered = await registry.isRegistered("nonexistent") - #expect(isRegistered == false) - } - - // MARK: - Available Commands Tests - - @Test("Available commands lists registered commands") - func availableCommands() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - await registry.register(AnotherCommand.self) - - let commands = await registry.availableCommands - - #expect(commands.contains("test")) - #expect(commands.contains("another")) - #expect(commands.count == 2) - } - - @Test("Available commands returns empty for new registry") - func availableCommandsEmpty() async { - let registry = CommandRegistry() - - let commands = await registry.availableCommands - - #expect(commands.isEmpty) - } - - @Test("Available commands are sorted") - func availableCommandsSorted() async { - let registry = CommandRegistry() - - await registry.register(AnotherCommand.self) - await registry.register(TestCommand.self) - - let commands = await registry.availableCommands - - #expect(commands == ["another", "test"]) - } - - // MARK: - Metadata Tests - - @Test("Get command metadata") - func getCommandMetadata() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - - let metadata = await registry.metadata(for: "test") - - #expect(metadata != nil) - #expect(metadata?.commandName == "test") - #expect(metadata?.abstract == "Test command") - #expect(metadata?.helpText == "This is a test command") - } - - @Test("Get metadata for unregistered command") - func getMetadataForUnregistered() async { - let registry = CommandRegistry() - - let metadata = await registry.metadata(for: "nonexistent") - - #expect(metadata == nil) - } - - // MARK: - Command Type Retrieval Tests - - @Test("Get command type by name") - func getCommandType() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - - let commandType = await registry.commandType(named: "test") - - #expect(commandType != nil) - } - - @Test("Get command type for unregistered command") - func getCommandTypeUnregistered() async { - let registry = CommandRegistry() - - let commandType = await registry.commandType(named: "nonexistent") - - #expect(commandType == nil) - } - - // MARK: - Command Creation Tests - - @Test("Create command instance") - func createCommandInstance() async throws { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - - let command = try await registry.createCommand(named: "test") - - #expect(command is TestCommand) - } - - @Test("Create command instance throws for unknown command") - func createCommandInstanceThrows() async { - let registry = CommandRegistry() - - await #expect(throws: CommandRegistryError.self) { - try await registry.createCommand(named: "unknown") - } - } - - // MARK: - Error Tests - - @Test("Unknown command error has description") - func unknownCommandError() { - let error = CommandRegistryError.unknownCommand("missing") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Unknown command") == true) - #expect(description?.contains("missing") == true) - } - - @Test("CommandRegistryError conforms to LocalizedError") - func errorConformsToLocalizedError() { - let error: any Error = CommandRegistryError.unknownCommand("test") - #expect(error is LocalizedError) - } - - // MARK: - Concurrent Access Tests - - @Test("Concurrent registration") - func concurrentRegistration() async { - let registry = CommandRegistry() - - await withTaskGroup(of: Void.self) { group in - group.addTask { - await registry.register(TestCommand.self) - } - group.addTask { - await registry.register(AnotherCommand.self) - } - } - - let commands = await registry.availableCommands - #expect(commands.count == 2) - } - - @Test("Concurrent reads") - func concurrentReads() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - - await withTaskGroup(of: Bool.self) { group in - for _ in 0..<10 { - group.addTask { - await registry.isRegistered("test") - } - } - - var results: [Bool] = [] - for await result in group { - results.append(result) - } - - #expect(results.allSatisfy { $0 == true }) - } - } - - @Test("Mixed concurrent operations") - func mixedConcurrentOperations() async { - let registry = CommandRegistry() - - await withTaskGroup(of: Void.self) { group in - group.addTask { - await registry.register(TestCommand.self) - } - group.addTask { - _ = await registry.isRegistered("test") - } - group.addTask { - _ = await registry.availableCommands - } - } - - let isRegistered = await registry.isRegistered("test") - #expect(isRegistered == true) - } - - // MARK: - Overwrite Tests - - @Test("Registering same command twice overwrites") - func registerCommandTwiceOverwrites() async { - let registry = CommandRegistry() - - await registry.register(TestCommand.self) - await registry.register(TestCommand.self) - - let commands = await registry.availableCommands - #expect(commands.count == 1) - #expect(commands.contains("test")) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift new file mode 100644 index 00000000..5756dc6e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -0,0 +1,166 @@ +// +// AuthTokenConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Configuration +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +@Suite("AuthTokenConfig Tests") +internal struct AuthTokenConfigTests { + private static func key(_ path: String) -> AbsoluteConfigKey { + AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) + } + + private static func configuration( + values: [String: ConfigValue] + ) -> MistDemoConfiguration { + var mapped: [AbsoluteConfigKey: ConfigValue] = [:] + for (path, value) in values { + mapped[key(path)] = value + } + return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + } + + @Test("Memberwise init applies defaults for port, host, openBrowser, container") + internal func memberwiseDefaults() { + let config = AuthTokenConfig(apiToken: "tok") + + #expect(config.apiToken == "tok") + #expect(config.containerIdentifier == MistDemoConstants.Defaults.containerIdentifier) + #expect(config.environment == .development) + #expect(config.port == 8_080) + #expect(config.host == "127.0.0.1") + // auth-token defaults to opening the browser. + #expect(config.openBrowser == true) + } + + @Test("Memberwise init accepts custom values for every field") + internal func memberwiseCustom() { + let config = AuthTokenConfig( + apiToken: "tok", + containerIdentifier: "iCloud.custom.id", + environment: .production, + port: 9_000, + host: "0.0.0.0", + openBrowser: false + ) + + #expect(config.apiToken == "tok") + #expect(config.containerIdentifier == "iCloud.custom.id") + #expect(config.environment == .production) + #expect(config.port == 9_000) + #expect(config.host == "0.0.0.0") + #expect(config.openBrowser == false) + } + + @Test("Configuration init throws missingRequired when api.token is absent") + internal func missingApiTokenThrows() async { + let configuration = Self.configuration(values: [:]) + + await #expect(throws: ConfigurationError.self) { + _ = try await AuthTokenConfig(configuration: configuration) + } + } + + @Test("Configuration init throws missingRequired when api.token is empty") + internal func emptyApiTokenThrows() async { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "") + ]) + + await #expect(throws: ConfigurationError.self) { + _ = try await AuthTokenConfig(configuration: configuration) + } + } + + @Test("Configuration init applies all defaults when only api.token is set") + internal func parsedDefaults() async throws { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz") + ]) + + let config = try await AuthTokenConfig(configuration: configuration) + + #expect(config.apiToken == "tok-xyz") + #expect(config.containerIdentifier == MistDemoConstants.Defaults.containerIdentifier) + #expect(config.environment == .development) + #expect(config.port == 8_080) + #expect(config.host == "127.0.0.1") + #expect(config.openBrowser == true) + } + + @Test("Configuration init honors every override key") + internal func parsedOverrides() async throws { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz"), + "container.identifier": .init(stringLiteral: "iCloud.custom.id"), + "environment": .init(stringLiteral: "production"), + "port": .init(integerLiteral: 9_090), + "host": .init(stringLiteral: "192.168.1.10"), + "no.browser": .init(booleanLiteral: true), + ]) + + let config = try await AuthTokenConfig(configuration: configuration) + + #expect(config.apiToken == "tok-xyz") + #expect(config.containerIdentifier == "iCloud.custom.id") + #expect(config.environment == .production) + #expect(config.port == 9_090) + #expect(config.host == "192.168.1.10") + #expect(config.openBrowser == false) + } + + @Test("--no-browser wins when both browser flags are set") + internal func noBrowserWinsOverBrowser() async throws { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz"), + "browser": .init(booleanLiteral: true), + "no.browser": .init(booleanLiteral: true), + ]) + + let config = try await AuthTokenConfig(configuration: configuration) + + #expect(config.openBrowser == false) + } + + @Test("Configuration init throws on invalid environment") + internal func invalidEnvironmentThrows() async { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz"), + "environment": .init(stringLiteral: "staging"), + ]) + + await #expect(throws: ConfigurationError.self) { + _ = try await AuthTokenConfig(configuration: configuration) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift new file mode 100644 index 00000000..b54071ad --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -0,0 +1,209 @@ +// +// AuthenticationCredentialsTests+ToConfiguration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension AuthenticationCredentialsTests { + @Suite( + "MistDemoConfig.toPrimaryCredentials", + .disabled( + if: TestPlatform.isWasm32, + "MistDemoConfig construction relies on Foundation IO unavailable on WASI" + ) + ) + internal struct ToPrimaryCredentialsTests { + @Test("public with raw private key produces serverToServer with .raw material") + internal func publicWithRawKey() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + database: .public(.prefers(.serverToServer)), + keyID: "test-key-id", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let credentials = try config.toPrimaryCredentials() + guard let s2s = credentials.serverToServer else { + Issue.record("Expected serverToServer credentials") + return + } + #expect(s2s.keyID == "test-key-id") + if case .raw = s2s.privateKey { + // expected + } else { + Issue.record("Expected .raw material, got \(s2s.privateKey)") + } + } + + @Test("public with private key file produces serverToServer with .file material") + internal func publicWithFilePath() throws { + let config = MistDemoConfig( + containerIdentifier: "iCloud.com.test.App", + apiToken: "test-api-token", + environment: .development, + database: .public(.prefers(.serverToServer)), + webAuthToken: nil, + keyID: "test-key-id", + privateKey: nil, + privateKeyFile: "/tmp/fake.pem", + host: "127.0.0.1", + port: 8_080, + authTimeout: 300, + skipAuth: false, + testAllAuth: false, + testApiOnly: false, + testAdaptive: false, + testServerToServer: false, + badCredentials: false + ) + + let credentials = try config.toPrimaryCredentials() + guard let s2s = credentials.serverToServer else { + Issue.record("Expected serverToServer credentials") + return + } + if case .file(let path) = s2s.privateKey { + #expect(path == "/tmp/fake.pem") + } else { + Issue.record("Expected .file material, got \(s2s.privateKey)") + } + } + + @Test("public missing keyID throws missingRequired(\"key.id\")") + internal func publicMissingKeyIDThrows() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + database: .public(.prefers(.serverToServer)), + keyID: "", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + do { + _ = try config.toPrimaryCredentials() + Issue.record("Expected ConfigurationError.missingRequired") + } catch let error as ConfigurationError { + if case .missingRequired(let key, _) = error { + #expect(key == "key.id") + } else { + Issue.record("Wrong ConfigurationError case: \(error)") + } + } + } + + @Test("public missing private key material throws missingRequired(\"private.key\")") + internal func publicMissingPrivateKeyThrows() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + database: .public(.prefers(.serverToServer)), + keyID: "test-key-id" + ) + + do { + _ = try config.toPrimaryCredentials() + Issue.record("Expected ConfigurationError.missingRequired") + } catch let error as ConfigurationError { + if case .missingRequired(let key, _) = error { + #expect(key == "private.key") + } else { + Issue.record("Wrong ConfigurationError case: \(error)") + } + } + } + + @Test("private database resolves to apiAuth credentials with web-auth token") + internal func privateHappyPath() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api", + database: .private, + webAuthToken: "web" + ) + + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer == nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") + } + + @Test("shared database resolves to apiAuth credentials with web-auth token") + internal func sharedHappyPath() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api", + database: .shared, + webAuthToken: "web" + ) + + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer == nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") + } + } + + @Suite( + "MistDemoConfig user-context credentials", + .disabled( + if: TestPlatform.isWasm32, + "MistDemoConfig construction relies on Foundation IO unavailable on WASI" + ) + ) + internal struct UserContextCredentialsTests { + @Test("public with web-auth embeds apiAuth alongside serverToServer") + internal func publicEmbedsAPIAuthWhenAvailable() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api", + database: .public(.prefers(.serverToServer)), + webAuthToken: "web", + keyID: "k", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer != nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") + #expect(config.hasUserContextCredentials) + } + + @Test("public without web-auth produces credentials without apiAuth") + internal func publicOmitsAPIAuthWhenWebAuthMissing() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", + database: .public(.prefers(.serverToServer)), + webAuthToken: nil, + keyID: "k", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer != nil) + #expect(credentials.apiAuth == nil) + #expect(!config.hasUserContextCredentials) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift new file mode 100644 index 00000000..bfacb43c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift @@ -0,0 +1,83 @@ +// +// AuthenticationCredentialsTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +@Suite("Credentials helpers") +internal enum AuthenticationCredentialsTests { + @Suite("PrivateKeyMaterial") + internal struct PrivateKeyMaterialTests { + @Test("loadPEM raw returns content unchanged when no escapes present") + internal func loadPEMRawPassthrough() throws { + let pem = "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----" + let material = PrivateKeyMaterial.raw(pem) + + #expect(try material.loadPEM() == pem) + } + + @Test("loadPEM raw unescapes literal backslash-n into newline") + internal func loadPEMRawUnescapesNewlines() throws { + let escaped = "-----BEGIN PRIVATE KEY-----\\nABC\\n-----END PRIVATE KEY-----" + let material = PrivateKeyMaterial.raw(escaped) + + let result = try material.loadPEM() + + #expect(result.contains("\n")) + #expect(!result.contains("\\n")) + } + + @Test( + "loadPEM file reads UTF-8 contents", + .disabled(if: TestPlatform.isWasm32, "WASI sandbox lacks reliable temp file IO") + ) + internal func loadPEMFileSuccess() throws { + let pem = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("mistdemo-loadpem-\(UUID().uuidString).pem") + try pem.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let material = PrivateKeyMaterial.file(path: url.path) + #expect(try material.loadPEM() == pem) + } + + @Test("loadPEM file throws when file is unreadable") + internal func loadPEMFileMissingThrows() throws { + let material = PrivateKeyMaterial.file(path: "/non/existent/key-\(UUID().uuidString).pem") + + #expect(throws: (any Error).self) { + _ = try material.loadPEM() + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+BasicInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+BasicInitialization.swift new file mode 100644 index 00000000..33abaabd --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+BasicInitialization.swift @@ -0,0 +1,97 @@ +// +// CreateConfigTests+BasicInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateConfigTests { + @Suite("Basic Initialization") + internal struct BasicInitialization { + @Test("CreateConfig initializes with default values") + internal func initializeWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig) + + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Note") + #expect(config.recordName == nil) + #expect(config.fields.isEmpty) + #expect(config.output == .json) + } + + @Test("CreateConfig initializes with custom zone") + internal func initializeWithCustomZone() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + zone: "customZone" + ) + + #expect(config.zone == "customZone") + #expect(config.recordType == "Note") + } + + @Test("CreateConfig initializes with custom record type") + internal func initializeWithCustomRecordType() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + recordType: "Article" + ) + + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Article") + } + + @Test("CreateConfig initializes with custom record name") + internal func initializeWithCustomRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + recordName: "myRecord123" + ) + + #expect(config.recordName == "myRecord123") + } + + @Test("CreateConfig initializes with nil record name") + internal func initializeWithNilRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + recordName: nil + ) + + #expect(config.recordName == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+ComplexInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+ComplexInitialization.swift new file mode 100644 index 00000000..8586d0e9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+ComplexInitialization.swift @@ -0,0 +1,62 @@ +// +// CreateConfigTests+ComplexInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateConfigTests { + @Suite("Complex Initialization") + internal struct ComplexInitialization { + @Test("CreateConfig initializes with all custom values") + internal func initializeWithAllCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let fields = [ + Field(name: "name", type: .string, value: "John Doe"), + Field(name: "age", type: .int64, value: "30"), + ] + let config = CreateConfig( + base: baseConfig, + zone: "customZone", + recordType: "Person", + recordName: "person001", + fields: fields, + output: .yaml + ) + + #expect(config.zone == "customZone") + #expect(config.recordType == "Person") + #expect(config.recordName == "person001") + #expect(config.fields.count == 2) + #expect(config.output == .yaml) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+EdgeCases.swift new file mode 100644 index 00000000..2b091d23 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+EdgeCases.swift @@ -0,0 +1,128 @@ +// +// CreateConfigTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateConfigTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("CreateConfig handles special characters in zone name") + internal func handleSpecialCharactersInZone() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + zone: "zone_with-special.chars" + ) + + #expect(config.zone == "zone_with-special.chars") + } + + @Test("CreateConfig handles special characters in record type") + internal func handleSpecialCharactersInRecordType() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + recordType: "Record_Type-123" + ) + + #expect(config.recordType == "Record_Type-123") + } + + @Test("CreateConfig handles special characters in record name") + internal func handleSpecialCharactersInRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + recordName: "record-name_with.special@chars" + ) + + #expect(config.recordName == "record-name_with.special@chars") + } + + @Test("CreateConfig handles field with empty string value") + internal func handleFieldWithEmptyValue() async throws { + let baseConfig = try await MistDemoConfig() + let field = Field(name: "emptyField", type: .string, value: "") + let config = CreateConfig( + base: baseConfig, + fields: [field] + ) + + #expect(config.fields.count == 1) + #expect(config.fields[0].value.isEmpty) + } + + @Test("CreateConfig handles field with whitespace value") + internal func handleFieldWithWhitespaceValue() async throws { + let baseConfig = try await MistDemoConfig() + let field = Field(name: "whitespaceField", type: .string, value: " ") + let config = CreateConfig( + base: baseConfig, + fields: [field] + ) + + #expect(config.fields.count == 1) + #expect(config.fields[0].value == " ") + } + + @Test("CreateConfig handles very long field value") + internal func handleVeryLongFieldValue() async throws { + let baseConfig = try await MistDemoConfig() + let longValue = String(repeating: "a", count: 1_000) + let field = Field(name: "longField", type: .string, value: longValue) + let config = CreateConfig( + base: baseConfig, + fields: [field] + ) + + #expect(config.fields.count == 1) + #expect(config.fields[0].value.count == 1_000) + } + + @Test("CreateConfig handles many fields") + internal func handleManyFields() async throws { + let baseConfig = try await MistDemoConfig() + let fields = (0..<20).map { index in + Field(name: "field\(index)", type: .string, value: "value\(index)") + } + let config = CreateConfig( + base: baseConfig, + fields: fields + ) + + #expect(config.fields.count == 20) + #expect(config.fields[0].name == "field0") + #expect(config.fields[19].name == "field19") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+FieldInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+FieldInitialization.swift new file mode 100644 index 00000000..8581d73b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+FieldInitialization.swift @@ -0,0 +1,107 @@ +// +// CreateConfigTests+FieldInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateConfigTests { + @Suite("Field Initialization") + internal struct FieldInitialization { + @Test("CreateConfig initializes with empty fields") + internal func initializeWithEmptyFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + fields: [] + ) + + #expect(config.fields.isEmpty) + } + + @Test("CreateConfig initializes with single field") + internal func initializeWithSingleField() async throws { + let baseConfig = try await MistDemoConfig() + let field = Field(name: "title", type: .string, value: "Hello World") + let config = CreateConfig( + base: baseConfig, + fields: [field] + ) + + #expect(config.fields.count == 1) + #expect(config.fields[0].name == "title") + #expect(config.fields[0].type == .string) + #expect(config.fields[0].value == "Hello World") + } + + @Test("CreateConfig initializes with multiple fields") + internal func initializeWithMultipleFields() async throws { + let baseConfig = try await MistDemoConfig() + let fields = [ + Field(name: "title", type: .string, value: "Test Title"), + Field(name: "count", type: .int64, value: "42"), + Field(name: "price", type: .double, value: "99.99"), + ] + let config = CreateConfig( + base: baseConfig, + fields: fields + ) + + #expect(config.fields.count == 3) + #expect(config.fields[0].name == "title") + #expect(config.fields[1].name == "count") + #expect(config.fields[2].name == "price") + } + + @Test("CreateConfig initializes with different field types") + internal func initializeWithDifferentFieldTypes() async throws { + let baseConfig = try await MistDemoConfig() + let fields = [ + Field(name: "stringField", type: .string, value: "text"), + Field(name: "intField", type: .int64, value: "100"), + Field(name: "doubleField", type: .double, value: "3.14"), + Field(name: "timestampField", type: .timestamp, value: "1234567890000"), + Field(name: "bytesField", type: .bytes, value: "ZGF0YQ=="), + ] + let config = CreateConfig( + base: baseConfig, + fields: fields + ) + + #expect(config.fields.count == 5) + #expect(config.fields[0].type == .string) + #expect(config.fields[1].type == .int64) + #expect(config.fields[2].type == .double) + #expect(config.fields[3].type == .timestamp) + #expect(config.fields[4].type == .bytes) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+OutputFormat.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+OutputFormat.swift new file mode 100644 index 00000000..a506616a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests+OutputFormat.swift @@ -0,0 +1,83 @@ +// +// CreateConfigTests+OutputFormat.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CreateConfigTests { + @Suite("Output Format") + internal struct OutputFormatTests { + @Test("CreateConfig initializes with JSON output format") + internal func initializeWithJSONOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + output: .json + ) + + #expect(config.output == .json) + } + + @Test("CreateConfig initializes with CSV output format") + internal func initializeWithCSVOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + output: .csv + ) + + #expect(config.output == .csv) + } + + @Test("CreateConfig initializes with table output format") + internal func initializeWithTableOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + output: .table + ) + + #expect(config.output == .table) + } + + @Test("CreateConfig initializes with YAML output format") + internal func initializeWithYAMLOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig( + base: baseConfig, + output: .yaml + ) + + #expect(config.output == .yaml) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests.swift new file mode 100644 index 00000000..ddf83def --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfig/CreateConfigTests.swift @@ -0,0 +1,33 @@ +// +// CreateConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CreateConfig") +internal enum CreateConfigTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfigTests.swift deleted file mode 100644 index fdf5316d..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CreateConfigTests.swift +++ /dev/null @@ -1,329 +0,0 @@ -// -// CreateConfigTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("CreateConfig Tests") -struct CreateConfigTests { - // MARK: - Basic Initialization Tests - - @Test("CreateConfig initializes with default values") - func initializeWithDefaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig(base: baseConfig) - - #expect(config.zone == "_defaultZone") - #expect(config.recordType == "Note") - #expect(config.recordName == nil) - #expect(config.fields.isEmpty) - #expect(config.output == .json) - } - - @Test("CreateConfig initializes with custom zone") - func initializeWithCustomZone() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - zone: "customZone" - ) - - #expect(config.zone == "customZone") - #expect(config.recordType == "Note") - } - - @Test("CreateConfig initializes with custom record type") - func initializeWithCustomRecordType() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - recordType: "Article" - ) - - #expect(config.zone == "_defaultZone") - #expect(config.recordType == "Article") - } - - @Test("CreateConfig initializes with custom record name") - func initializeWithCustomRecordName() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - recordName: "myRecord123" - ) - - #expect(config.recordName == "myRecord123") - } - - @Test("CreateConfig initializes with nil record name") - func initializeWithNilRecordName() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - recordName: nil - ) - - #expect(config.recordName == nil) - } - - // MARK: - Field Initialization Tests - - @Test("CreateConfig initializes with empty fields") - func initializeWithEmptyFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - fields: [] - ) - - #expect(config.fields.isEmpty) - } - - @Test("CreateConfig initializes with single field") - func initializeWithSingleField() async throws { - let baseConfig = try await MistDemoConfig() - let field = Field(name: "title", type: .string, value: "Hello World") - let config = CreateConfig( - base: baseConfig, - fields: [field] - ) - - #expect(config.fields.count == 1) - #expect(config.fields[0].name == "title") - #expect(config.fields[0].type == .string) - #expect(config.fields[0].value == "Hello World") - } - - @Test("CreateConfig initializes with multiple fields") - func initializeWithMultipleFields() async throws { - let baseConfig = try await MistDemoConfig() - let fields = [ - Field(name: "title", type: .string, value: "Test Title"), - Field(name: "count", type: .int64, value: "42"), - Field(name: "price", type: .double, value: "99.99") - ] - let config = CreateConfig( - base: baseConfig, - fields: fields - ) - - #expect(config.fields.count == 3) - #expect(config.fields[0].name == "title") - #expect(config.fields[1].name == "count") - #expect(config.fields[2].name == "price") - } - - @Test("CreateConfig initializes with different field types") - func initializeWithDifferentFieldTypes() async throws { - let baseConfig = try await MistDemoConfig() - let fields = [ - Field(name: "stringField", type: .string, value: "text"), - Field(name: "intField", type: .int64, value: "100"), - Field(name: "doubleField", type: .double, value: "3.14"), - Field(name: "timestampField", type: .timestamp, value: "1234567890000"), - Field(name: "bytesField", type: .bytes, value: "ZGF0YQ==") - ] - let config = CreateConfig( - base: baseConfig, - fields: fields - ) - - #expect(config.fields.count == 5) - #expect(config.fields[0].type == .string) - #expect(config.fields[1].type == .int64) - #expect(config.fields[2].type == .double) - #expect(config.fields[3].type == .timestamp) - #expect(config.fields[4].type == .bytes) - } - - // MARK: - Output Format Tests - - @Test("CreateConfig initializes with JSON output format") - func initializeWithJSONOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - output: .json - ) - - #expect(config.output == .json) - } - - @Test("CreateConfig initializes with CSV output format") - func initializeWithCSVOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - output: .csv - ) - - #expect(config.output == .csv) - } - - @Test("CreateConfig initializes with table output format") - func initializeWithTableOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - output: .table - ) - - #expect(config.output == .table) - } - - @Test("CreateConfig initializes with YAML output format") - func initializeWithYAMLOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - output: .yaml - ) - - #expect(config.output == .yaml) - } - - // MARK: - Complex Initialization Tests - - @Test("CreateConfig initializes with all custom values") - func initializeWithAllCustomValues() async throws { - let baseConfig = try await MistDemoConfig() - let fields = [ - Field(name: "name", type: .string, value: "John Doe"), - Field(name: "age", type: .int64, value: "30") - ] - let config = CreateConfig( - base: baseConfig, - zone: "customZone", - recordType: "Person", - recordName: "person001", - fields: fields, - output: .yaml - ) - - #expect(config.zone == "customZone") - #expect(config.recordType == "Person") - #expect(config.recordName == "person001") - #expect(config.fields.count == 2) - #expect(config.output == .yaml) - } - - // MARK: - Edge Cases - - @Test("CreateConfig handles special characters in zone name") - func handleSpecialCharactersInZone() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - zone: "zone_with-special.chars" - ) - - #expect(config.zone == "zone_with-special.chars") - } - - @Test("CreateConfig handles special characters in record type") - func handleSpecialCharactersInRecordType() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - recordType: "Record_Type-123" - ) - - #expect(config.recordType == "Record_Type-123") - } - - @Test("CreateConfig handles special characters in record name") - func handleSpecialCharactersInRecordName() async throws { - let baseConfig = try await MistDemoConfig() - let config = CreateConfig( - base: baseConfig, - recordName: "record-name_with.special@chars" - ) - - #expect(config.recordName == "record-name_with.special@chars") - } - - @Test("CreateConfig handles field with empty string value") - func handleFieldWithEmptyValue() async throws { - let baseConfig = try await MistDemoConfig() - let field = Field(name: "emptyField", type: .string, value: "") - let config = CreateConfig( - base: baseConfig, - fields: [field] - ) - - #expect(config.fields.count == 1) - #expect(config.fields[0].value == "") - } - - @Test("CreateConfig handles field with whitespace value") - func handleFieldWithWhitespaceValue() async throws { - let baseConfig = try await MistDemoConfig() - let field = Field(name: "whitespaceField", type: .string, value: " ") - let config = CreateConfig( - base: baseConfig, - fields: [field] - ) - - #expect(config.fields.count == 1) - #expect(config.fields[0].value == " ") - } - - @Test("CreateConfig handles very long field value") - func handleVeryLongFieldValue() async throws { - let baseConfig = try await MistDemoConfig() - let longValue = String(repeating: "a", count: 1000) - let field = Field(name: "longField", type: .string, value: longValue) - let config = CreateConfig( - base: baseConfig, - fields: [field] - ) - - #expect(config.fields.count == 1) - #expect(config.fields[0].value.count == 1000) - } - - @Test("CreateConfig handles many fields") - func handleManyFields() async throws { - let baseConfig = try await MistDemoConfig() - let fields = (0..<20).map { index in - Field(name: "field\(index)", type: .string, value: "value\(index)") - } - let config = CreateConfig( - base: baseConfig, - fields: fields - ) - - #expect(config.fields.count == 20) - #expect(config.fields[0].name == "field0") - #expect(config.fields[19].name == "field19") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+BasicInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+BasicInitialization.swift new file mode 100644 index 00000000..e9ac54d6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+BasicInitialization.swift @@ -0,0 +1,71 @@ +// +// CurrentUserConfigTests+BasicInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserConfigTests { + @Suite("Basic Initialization") + internal struct BasicInitialization { + @Test("CurrentUserConfig initializes with default values") + internal func initializeWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig(base: baseConfig) + + #expect(config.fields == nil) + #expect(config.output == .json) + } + + @Test("CurrentUserConfig initializes with nil fields") + internal func initializeWithNilFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: nil + ) + + #expect(config.fields == nil) + } + + @Test("CurrentUserConfig initializes with empty fields array") + internal func initializeWithEmptyFieldsArray() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: [] + ) + + #expect(config.fields != nil) + #expect(config.fields?.isEmpty == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+ComplexInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+ComplexInitialization.swift new file mode 100644 index 00000000..2ce98a75 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+ComplexInitialization.swift @@ -0,0 +1,78 @@ +// +// CurrentUserConfigTests+ComplexInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserConfigTests { + @Suite("Complex Initialization") + internal struct ComplexInitialization { + @Test("CurrentUserConfig initializes with all custom values") + internal func initializeWithAllCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["userRecordName", "firstName", "lastName"], + output: .yaml + ) + + #expect(config.fields?.count == 3) + #expect(config.output == .yaml) + } + + @Test("CurrentUserConfig handles fields and JSON output") + internal func handleFieldsWithJSONOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["userRecordName", "emailAddress"], + output: .json + ) + + #expect(config.fields?.count == 2) + #expect(config.output == .json) + } + + @Test("CurrentUserConfig handles fields and CSV output") + internal func handleFieldsWithCSVOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["firstName", "lastName"], + output: .csv + ) + + #expect(config.fields?.count == 2) + #expect(config.output == .csv) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+EdgeCases.swift new file mode 100644 index 00000000..c03dd3fb --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+EdgeCases.swift @@ -0,0 +1,92 @@ +// +// CurrentUserConfigTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserConfigTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("CurrentUserConfig handles single character field name") + internal func handleSingleCharacterFieldName() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["x"] + ) + + #expect(config.fields?.count == 1) + #expect(config.fields?[0] == "x") + } + + @Test("CurrentUserConfig handles fields with special characters") + internal func handleFieldsWithSpecialCharacters() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["field_name", "field-with-dash", "field.with.dot"] + ) + + #expect(config.fields?.count == 3) + #expect(config.fields?[0] == "field_name") + #expect(config.fields?[1] == "field-with-dash") + #expect(config.fields?[2] == "field.with.dot") + } + + @Test("CurrentUserConfig handles very long field name") + internal func handleVeryLongFieldName() async throws { + let baseConfig = try await MistDemoConfig() + let longFieldName = String(repeating: "a", count: 100) + let config = CurrentUserConfig( + base: baseConfig, + fields: [longFieldName] + ) + + #expect(config.fields?.count == 1) + #expect(config.fields?[0].count == 100) + } + + @Test("CurrentUserConfig handles many fields") + internal func handleManyFields() async throws { + let baseConfig = try await MistDemoConfig() + let fields = (0..<20).map { "field\($0)" } + let config = CurrentUserConfig( + base: baseConfig, + fields: fields + ) + + #expect(config.fields?.count == 20) + #expect(config.fields?[0] == "field0") + #expect(config.fields?[19] == "field19") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+Fields.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+Fields.swift new file mode 100644 index 00000000..c9b26c0a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+Fields.swift @@ -0,0 +1,84 @@ +// +// CurrentUserConfigTests+Fields.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserConfigTests { + @Suite("Fields") + internal struct Fields { + @Test("CurrentUserConfig initializes with single field") + internal func initializeWithSingleField() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["userRecordName"] + ) + + #expect(config.fields?.count == 1) + #expect(config.fields?[0] == "userRecordName") + } + + @Test("CurrentUserConfig initializes with multiple fields") + internal func initializeWithMultipleFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: ["userRecordName", "firstName", "lastName"] + ) + + #expect(config.fields?.count == 3) + #expect(config.fields?[0] == "userRecordName") + #expect(config.fields?[1] == "firstName") + #expect(config.fields?[2] == "lastName") + } + + @Test("CurrentUserConfig handles standard user fields") + internal func handleStandardUserFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + fields: [ + "userRecordName", + "firstName", + "lastName", + "emailAddress", + "iCloudId", + ] + ) + + #expect(config.fields?.count == 5) + #expect(config.fields?.contains("userRecordName") == true) + #expect(config.fields?.contains("emailAddress") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+OutputFormat.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+OutputFormat.swift new file mode 100644 index 00000000..c1e95a32 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests+OutputFormat.swift @@ -0,0 +1,83 @@ +// +// CurrentUserConfigTests+OutputFormat.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CurrentUserConfigTests { + @Suite("Output Format") + internal struct OutputFormatTests { + @Test("CurrentUserConfig initializes with JSON output format") + internal func initializeWithJSONOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + output: .json + ) + + #expect(config.output == .json) + } + + @Test("CurrentUserConfig initializes with CSV output format") + internal func initializeWithCSVOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + output: .csv + ) + + #expect(config.output == .csv) + } + + @Test("CurrentUserConfig initializes with table output format") + internal func initializeWithTableOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + output: .table + ) + + #expect(config.output == .table) + } + + @Test("CurrentUserConfig initializes with YAML output format") + internal func initializeWithYAMLOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = CurrentUserConfig( + base: baseConfig, + output: .yaml + ) + + #expect(config.output == .yaml) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests.swift new file mode 100644 index 00000000..5ef7111d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfig/CurrentUserConfigTests.swift @@ -0,0 +1,33 @@ +// +// CurrentUserConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CurrentUserConfig") +internal enum CurrentUserConfigTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfigTests.swift deleted file mode 100644 index b8d40c2c..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/CurrentUserConfigTests.swift +++ /dev/null @@ -1,260 +0,0 @@ -// -// CurrentUserConfigTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("CurrentUserConfig Tests") -struct CurrentUserConfigTests { - // MARK: - Basic Initialization Tests - - @Test("CurrentUserConfig initializes with default values") - func initializeWithDefaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig(base: baseConfig) - - #expect(config.fields == nil) - #expect(config.output == .json) - } - - @Test("CurrentUserConfig initializes with nil fields") - func initializeWithNilFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: nil - ) - - #expect(config.fields == nil) - } - - @Test("CurrentUserConfig initializes with empty fields array") - func initializeWithEmptyFieldsArray() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: [] - ) - - #expect(config.fields != nil) - #expect(config.fields?.isEmpty == true) - } - - // MARK: - Fields Tests - - @Test("CurrentUserConfig initializes with single field") - func initializeWithSingleField() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["userRecordName"] - ) - - #expect(config.fields?.count == 1) - #expect(config.fields?[0] == "userRecordName") - } - - @Test("CurrentUserConfig initializes with multiple fields") - func initializeWithMultipleFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["userRecordName", "firstName", "lastName"] - ) - - #expect(config.fields?.count == 3) - #expect(config.fields?[0] == "userRecordName") - #expect(config.fields?[1] == "firstName") - #expect(config.fields?[2] == "lastName") - } - - @Test("CurrentUserConfig handles standard user fields") - func handleStandardUserFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: [ - "userRecordName", - "firstName", - "lastName", - "emailAddress", - "iCloudId" - ] - ) - - #expect(config.fields?.count == 5) - #expect(config.fields?.contains("userRecordName") == true) - #expect(config.fields?.contains("emailAddress") == true) - } - - // MARK: - Output Format Tests - - @Test("CurrentUserConfig initializes with JSON output format") - func initializeWithJSONOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - output: .json - ) - - #expect(config.output == .json) - } - - @Test("CurrentUserConfig initializes with CSV output format") - func initializeWithCSVOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - output: .csv - ) - - #expect(config.output == .csv) - } - - @Test("CurrentUserConfig initializes with table output format") - func initializeWithTableOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - output: .table - ) - - #expect(config.output == .table) - } - - @Test("CurrentUserConfig initializes with YAML output format") - func initializeWithYAMLOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - output: .yaml - ) - - #expect(config.output == .yaml) - } - - // MARK: - Complex Initialization Tests - - @Test("CurrentUserConfig initializes with all custom values") - func initializeWithAllCustomValues() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["userRecordName", "firstName", "lastName"], - output: .yaml - ) - - #expect(config.fields?.count == 3) - #expect(config.output == .yaml) - } - - @Test("CurrentUserConfig handles fields and JSON output") - func handleFieldsWithJSONOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["userRecordName", "emailAddress"], - output: .json - ) - - #expect(config.fields?.count == 2) - #expect(config.output == .json) - } - - @Test("CurrentUserConfig handles fields and CSV output") - func handleFieldsWithCSVOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["firstName", "lastName"], - output: .csv - ) - - #expect(config.fields?.count == 2) - #expect(config.output == .csv) - } - - // MARK: - Edge Cases - - @Test("CurrentUserConfig handles single character field name") - func handleSingleCharacterFieldName() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["x"] - ) - - #expect(config.fields?.count == 1) - #expect(config.fields?[0] == "x") - } - - @Test("CurrentUserConfig handles fields with special characters") - func handleFieldsWithSpecialCharacters() async throws { - let baseConfig = try await MistDemoConfig() - let config = CurrentUserConfig( - base: baseConfig, - fields: ["field_name", "field-with-dash", "field.with.dot"] - ) - - #expect(config.fields?.count == 3) - #expect(config.fields?[0] == "field_name") - #expect(config.fields?[1] == "field-with-dash") - #expect(config.fields?[2] == "field.with.dot") - } - - @Test("CurrentUserConfig handles very long field name") - func handleVeryLongFieldName() async throws { - let baseConfig = try await MistDemoConfig() - let longFieldName = String(repeating: "a", count: 100) - let config = CurrentUserConfig( - base: baseConfig, - fields: [longFieldName] - ) - - #expect(config.fields?.count == 1) - #expect(config.fields?[0].count == 100) - } - - @Test("CurrentUserConfig handles many fields") - func handleManyFields() async throws { - let baseConfig = try await MistDemoConfig() - let fields = (0..<20).map { "field\($0)" } - let config = CurrentUserConfig( - base: baseConfig, - fields: fields - ) - - #expect(config.fields?.count == 20) - #expect(config.fields?[0] == "field0") - #expect(config.fields?[19] == "field19") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteConfigTests.swift new file mode 100644 index 00000000..0520e99f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteConfigTests.swift @@ -0,0 +1,129 @@ +// +// DeleteConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("DeleteConfig Tests") +internal struct DeleteConfigTests { + @Test("DeleteConfig initializes with defaults") + internal func initializeWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig(base: baseConfig, recordName: "rec-1") + + #expect(config.recordName == "rec-1") + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Note") + #expect(config.recordChangeTag == nil) + #expect(config.force == false) + #expect(config.output == .json) + } + + @Test("DeleteConfig initializes with custom zone and record type") + internal func initializeWithCustomZoneAndType() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig( + base: baseConfig, + zone: "myZone", + recordType: "Article", + recordName: "rec-1" + ) + + #expect(config.zone == "myZone") + #expect(config.recordType == "Article") + } + + @Test("DeleteConfig accepts a record change tag") + internal func recordChangeTag() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig( + base: baseConfig, + recordName: "rec-1", + recordChangeTag: "tag-xyz" + ) + + #expect(config.recordChangeTag == "tag-xyz") + } + + @Test("DeleteConfig defaults force to false") + internal func forceDefaultsFalse() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig(base: baseConfig, recordName: "rec-1") + + #expect(config.force == false) + } + + @Test("DeleteConfig accepts force=true") + internal func forceCanBeTrue() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig(base: baseConfig, recordName: "rec-1", force: true) + + #expect(config.force == true) + } + + @Test( + "DeleteConfig output formats round-trip", arguments: [OutputFormat.json, .table, .csv, .yaml]) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig(base: baseConfig, recordName: "rec-1", output: format) + + #expect(config.output == format) + } + + @Test("DeleteConfig handles all custom values together") + internal func allCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig( + base: baseConfig, + zone: "Z", + recordType: "T", + recordName: "R", + recordChangeTag: "tag", + force: true, + output: .yaml + ) + + #expect(config.zone == "Z") + #expect(config.recordType == "T") + #expect(config.recordName == "R") + #expect(config.recordChangeTag == "tag") + #expect(config.force == true) + #expect(config.output == .yaml) + } + + @Test("DeleteConfig preserves special characters in record name") + internal func specialCharactersInRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = DeleteConfig(base: baseConfig, recordName: "rec-name_with.special@chars") + + #expect(config.recordName == "rec-name_with.special@chars") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteErrorTests.swift new file mode 100644 index 00000000..ab9cbc62 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteErrorTests.swift @@ -0,0 +1,59 @@ +// +// DeleteErrorTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("DeleteError Tests") +internal struct DeleteErrorTests { + @Test("recordNameRequired has a description") + internal func recordNameRequiredDescription() { + let error = DeleteError.recordNameRequired + #expect(error.errorDescription != nil) + } + + @Test("conflict description includes the reason when present") + internal func conflictWithReason() { + let error = DeleteError.conflict(reason: "ATOMIC_ERROR") + #expect(error.errorDescription?.contains("ATOMIC_ERROR") == true) + } + + @Test("conflict description is generic when reason is nil") + internal func conflictNoReason() { + let error = DeleteError.conflict(reason: nil) + #expect(error.errorDescription?.contains("conflict") == true) + } + + @Test("conflict suggests --force as a remedy") + internal func conflictRecoveryMentionsForce() { + let error = DeleteError.conflict(reason: nil) + #expect(error.recoverySuggestion?.contains("--force") == true) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteResultTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteResultTests.swift new file mode 100644 index 00000000..2b61be75 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DeleteResultTests.swift @@ -0,0 +1,47 @@ +// +// DeleteResultTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("DeleteResult Tests") +internal struct DeleteResultTests { + @Test("DeleteResult encodes deleted=true by default") + internal func defaultsToDeletedTrue() throws { + let result = DeleteResult(recordName: "rec-1", recordType: "Note") + let data = try JSONEncoder().encode(result) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains("\"deleted\":true")) + #expect(json.contains("\"recordName\":\"rec-1\"")) + #expect(json.contains("\"recordType\":\"Note\"")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DemoErrorsConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DemoErrorsConfigTests.swift new file mode 100644 index 00000000..1bb52ab6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/DemoErrorsConfigTests.swift @@ -0,0 +1,74 @@ +// +// DemoErrorsConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("DemoErrorsConfig Tests") +internal struct DemoErrorsConfigTests { + @Test("DemoErrorsConfig defaults scenario to all") + internal func defaultsAll() async throws { + let baseConfig = try await MistDemoConfig() + let config = DemoErrorsConfig(base: baseConfig) + + #expect(config.scenario == .all) + } + + @Test( + "DemoErrorsConfig accepts each ErrorScenario value", + arguments: ErrorScenario.allCases + ) + internal func eachScenario(scenario: ErrorScenario) async throws { + let baseConfig = try await MistDemoConfig() + let config = DemoErrorsConfig(base: baseConfig, scenario: scenario) + + #expect(config.scenario == scenario) + } + + @Test("ErrorScenario raw values match documented HTTP statuses") + internal func rawValues() { + #expect(ErrorScenario.all.rawValue == "all") + #expect(ErrorScenario.unauthorized.rawValue == "401") + #expect(ErrorScenario.notFound.rawValue == "404") + #expect(ErrorScenario.conflict.rawValue == "409") + } + + @Test("DemoErrorsError.invalidScenario lists every legal scenario in its message") + internal func invalidScenarioMessage() { + let error = DemoErrorsError.invalidScenario("bogus") + let message = error.errorDescription ?? "" + + #expect(message.contains("bogus")) + for scenario in ErrorScenario.allCases { + #expect(message.contains(scenario.rawValue)) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift new file mode 100644 index 00000000..594ad154 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift @@ -0,0 +1,93 @@ +// +// FetchChangesConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("FetchChangesConfig Tests") +internal struct FetchChangesConfigTests { + @Test("FetchChangesConfig defaults zone to _defaultZone, fetchAll false, output table") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig(base: baseConfig) + + #expect(config.syncToken == nil) + #expect(config.zone == "_defaultZone") + #expect(config.fetchAll == false) + #expect(config.limit == nil) + #expect(config.output == .table) + } + + @Test("FetchChangesConfig accepts custom zone, syncToken and limit") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig( + base: baseConfig, + syncToken: "tok-1", + zone: "myZone", + fetchAll: true, + limit: 50 + ) + + #expect(config.syncToken == "tok-1") + #expect(config.zone == "myZone") + #expect(config.fetchAll == true) + #expect(config.limit == 50) + } + + @Test( + "FetchChangesConfig output formats round-trip", + arguments: [OutputFormat.json, .table, .csv, .yaml] + ) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig(base: baseConfig, output: format) + + #expect(config.output == format) + } + + @Test("FetchChangesConfig fetchAll true with no syncToken parses as initial fetch") + internal func initialFetchSemantics() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig(base: baseConfig, fetchAll: true) + + #expect(config.fetchAll == true) + #expect(config.syncToken == nil) + } + + @Test("FetchChangesConfig limit nil means fetch with default page size") + internal func nilLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig(base: baseConfig, limit: nil) + + #expect(config.limit == nil) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+BasicParsing.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+BasicParsing.swift new file mode 100644 index 00000000..2a546e21 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+BasicParsing.swift @@ -0,0 +1,74 @@ +// +// FieldTests+BasicParsing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("Basic Parsing") + internal struct BasicParsing { + @Test("Parse basic string field") + internal func parseBasicStringField() throws { + let field = try Field(parsing: "title:string:Hello World") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == "Hello World") + } + + @Test("Parse int64 field") + internal func parseInt64Field() throws { + let field = try Field(parsing: "count:int64:42") + + #expect(field.name == "count") + #expect(field.type == .int64) + #expect(field.value == "42") + } + + @Test("Parse double field") + internal func parseDoubleField() throws { + let field = try Field(parsing: "price:double:19.99") + + #expect(field.name == "price") + #expect(field.type == .double) + #expect(field.value == "19.99") + } + + @Test("Parse timestamp field") + internal func parseTimestampField() throws { + let field = try Field(parsing: "createdAt:timestamp:2024-01-15T10:30:00Z") + + #expect(field.name == "createdAt") + #expect(field.type == .timestamp) + #expect(field.value == "2024-01-15T10:30:00Z") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+CaseSensitivity.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+CaseSensitivity.swift new file mode 100644 index 00000000..279ae0b9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+CaseSensitivity.swift @@ -0,0 +1,56 @@ +// +// FieldTests+CaseSensitivity.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("Case Sensitivity") + internal struct CaseSensitivity { + @Test("Parse field with uppercase type (normalized to lowercase)") + internal func parseFieldWithUppercaseType() throws { + let field = try Field(parsing: "title:STRING:value") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == "value") + } + + @Test("Parse field with mixed case type") + internal func parseFieldWithMixedCaseType() throws { + let field = try Field(parsing: "count:InT64:42") + + #expect(field.name == "count") + #expect(field.type == .int64) + #expect(field.value == "42") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ColonHandling.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ColonHandling.swift new file mode 100644 index 00000000..f822f2e3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ColonHandling.swift @@ -0,0 +1,65 @@ +// +// FieldTests+ColonHandling.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("Colon Handling") + internal struct ColonHandling { + @Test("Parse field with colons in value (URL)") + internal func parseFieldWithColonsInURL() throws { + let field = try Field(parsing: "url:string:https://example.com:8080/path") + + #expect(field.name == "url") + #expect(field.type == .string) + #expect(field.value == "https://example.com:8080/path") + } + + @Test("Parse field with colons in value (time)") + internal func parseFieldWithColonsInTime() throws { + let field = try Field(parsing: "time:string:10:30:45") + + #expect(field.name == "time") + #expect(field.type == .string) + #expect(field.value == "10:30:45") + } + + @Test("Parse field with multiple colons in value") + internal func parseFieldWithMultipleColons() throws { + let field = try Field(parsing: "data:string:a:b:c:d:e") + + #expect(field.name == "data") + #expect(field.type == .string) + #expect(field.value == "a:b:c:d:e") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+EdgeCases.swift new file mode 100644 index 00000000..64429ba7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+EdgeCases.swift @@ -0,0 +1,92 @@ +// +// FieldTests+EdgeCases.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("Parse field with empty value") + internal func parseFieldWithEmptyValue() throws { + let field = try Field(parsing: "title:string:") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value.isEmpty) + } + + @Test("Parse field with Unicode in value") + internal func parseFieldWithUnicode() throws { + let field = try Field(parsing: "message:string:こんにちは世界") + + #expect(field.name == "message") + #expect(field.type == .string) + #expect(field.value == "こんにちは世界") + } + + @Test("Parse field with emoji in value") + internal func parseFieldWithEmoji() throws { + let field = try Field(parsing: "reaction:string:👍🎉🚀") + + #expect(field.name == "reaction") + #expect(field.type == .string) + #expect(field.value == "👍🎉🚀") + } + + @Test("Parse field with special characters in value") + internal func parseFieldWithSpecialCharacters() throws { + let field = try Field(parsing: "data:string:!@#$%^&*()_+-=[]{}|;'\"<>,.?/~`") + + #expect(field.name == "data") + #expect(field.type == .string) + #expect(field.value == "!@#$%^&*()_+-=[]{}|;'\"<>,.?/~`") + } + + @Test("Parse field with newline in value") + internal func parseFieldWithNewlineInValue() throws { + let field = try Field(parsing: "text:string:line1\nline2") + + #expect(field.name == "text") + #expect(field.type == .string) + #expect(field.value == "line1\nline2") + } + + @Test("Parse field with tab in value") + internal func parseFieldWithTabInValue() throws { + let field = try Field(parsing: "text:string:col1\tcol2") + + #expect(field.name == "text") + #expect(field.type == .string) + #expect(field.value == "col1\tcol2") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ErrorCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ErrorCases.swift new file mode 100644 index 00000000..e6dd6c5b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ErrorCases.swift @@ -0,0 +1,80 @@ +// +// FieldTests+ErrorCases.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("Error Cases") + internal struct ErrorCases { + @Test("Parse field with empty name throws error") + internal func parseFieldWithEmptyName() { + #expect(throws: FieldParsingError.self) { + try Field(parsing: ":string:value") + } + } + + @Test("Parse field with whitespace-only name throws error") + internal func parseFieldWithWhitespaceOnlyName() { + #expect(throws: FieldParsingError.self) { + try Field(parsing: " :string:value") + } + } + + @Test("Parse field with unknown type throws error") + internal func parseFieldWithUnknownType() { + #expect(throws: FieldParsingError.self) { + try Field(parsing: "title:unknown:value") + } + } + + @Test("Parse field with invalid format (too few parts)") + internal func parseFieldWithTooFewParts() { + #expect(throws: FieldParsingError.self) { + try Field(parsing: "title:string") + } + } + + @Test("Parse field with invalid format (one part)") + internal func parseFieldWithOnePart() { + #expect(throws: FieldParsingError.self) { + try Field(parsing: "title") + } + } + + @Test("Parse field with invalid format (empty string)") + internal func parseFieldWithEmptyString() { + #expect(throws: FieldParsingError.self) { + try Field(parsing: "") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ParseMultiple.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ParseMultiple.swift new file mode 100644 index 00000000..bbedf8ea --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+ParseMultiple.swift @@ -0,0 +1,74 @@ +// +// FieldTests+ParseMultiple.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("parseMultiple") + internal struct ParseMultiple { + @Test("Parse multiple valid fields") + internal func parseMultipleValidFields() throws { + let inputs = [ + "title:string:Hello", + "count:int64:42", + "price:double:19.99", + ] + + let fields = try Field.parseMultiple(inputs) + + #expect(fields.count == 3) + #expect(fields[0].name == "title") + #expect(fields[1].name == "count") + #expect(fields[2].name == "price") + } + + @Test("Parse multiple fields with empty array") + internal func parseMultipleFieldsWithEmptyArray() throws { + let fields = try Field.parseMultiple([]) + + #expect(fields.isEmpty) + } + + @Test("Parse multiple fields throws on first invalid") + internal func parseMultipleFieldsThrowsOnInvalid() { + let inputs = [ + "title:string:Hello", + "invalid", + "price:double:19.99", + ] + + #expect(throws: FieldParsingError.self) { + try Field.parseMultiple(inputs) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+WhitespaceHandling.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+WhitespaceHandling.swift new file mode 100644 index 00000000..8d5a9537 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests+WhitespaceHandling.swift @@ -0,0 +1,74 @@ +// +// FieldTests+WhitespaceHandling.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTests { + @Suite("Whitespace Handling") + internal struct WhitespaceHandling { + @Test("Parse field with leading/trailing whitespace in name") + internal func parseFieldWithWhitespaceInName() throws { + let field = try Field(parsing: " title :string:value") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == "value") + } + + @Test("Parse field with leading/trailing whitespace in type") + internal func parseFieldWithWhitespaceInType() throws { + let field = try Field(parsing: "title: string :value") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == "value") + } + + @Test("Parse field preserving whitespace in value") + internal func parseFieldPreservingWhitespaceInValue() throws { + let field = try Field(parsing: "title:string: Hello World ") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == " Hello World ") + } + + @Test("Parse field with only whitespace in value") + internal func parseFieldWithOnlyWhitespaceInValue() throws { + let field = try Field(parsing: "title:string: ") + + #expect(field.name == "title") + #expect(field.type == .string) + #expect(field.value == " ") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests.swift new file mode 100644 index 00000000..2ba8acff --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Field/FieldTests.swift @@ -0,0 +1,33 @@ +// +// FieldTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("Field Parsing") +internal enum FieldTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+EmptyFieldName.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+EmptyFieldName.swift new file mode 100644 index 00000000..4436c94b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+EmptyFieldName.swift @@ -0,0 +1,80 @@ +// +// FieldParsingErrorTests+EmptyFieldName.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldParsingErrorTests { + @Suite("emptyFieldName Error") + internal struct EmptyFieldName { + @Test("emptyFieldName error has correct description") + internal func emptyFieldNameErrorDescription() { + let error = FieldParsingError.emptyFieldName(":string:value") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Empty field name") == true) + #expect(description?.contains(":string:value") == true) + } + + @Test("emptyFieldName error is thrown for empty name") + internal func emptyFieldNameErrorThrown() { + do { + _ = try Field(parsing: ":string:value") + Issue.record("Expected emptyFieldName error to be thrown") + } catch let error as FieldParsingError { + if case .emptyFieldName = error { + // Success + } else { + Issue.record("Expected emptyFieldName error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + + @Test("emptyFieldName error is thrown for whitespace-only name") + internal func emptyFieldNameErrorThrownForWhitespace() { + do { + _ = try Field(parsing: " :string:value") + Issue.record("Expected emptyFieldName error to be thrown") + } catch let error as FieldParsingError { + if case .emptyFieldName = error { + // Success + } else { + Issue.record("Expected emptyFieldName error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+InvalidFormat.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+InvalidFormat.swift new file mode 100644 index 00000000..8b4ca785 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+InvalidFormat.swift @@ -0,0 +1,65 @@ +// +// FieldParsingErrorTests+InvalidFormat.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldParsingErrorTests { + @Suite("invalidFormat Error") + internal struct InvalidFormat { + @Test("invalidFormat error has correct description") + internal func invalidFormatErrorDescription() { + let error = FieldParsingError.invalidFormat("title:string", expected: "name:type:value") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Invalid field format") == true) + #expect(description?.contains("title:string") == true) + #expect(description?.contains("name:type:value") == true) + } + + @Test("invalidFormat error is thrown for missing parts") + internal func invalidFormatErrorThrown() { + do { + _ = try Field(parsing: "incomplete") + Issue.record("Expected invalidFormat error to be thrown") + } catch let error as FieldParsingError { + if case .invalidFormat = error { + // Success + } else { + Issue.record("Expected invalidFormat error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+InvalidValueForType.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+InvalidValueForType.swift new file mode 100644 index 00000000..2e1cef4a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+InvalidValueForType.swift @@ -0,0 +1,92 @@ +// +// FieldParsingErrorTests+InvalidValueForType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldParsingErrorTests { + @Suite("invalidValueForType Error") + internal struct InvalidValueForType { + @Test("invalidValueForType error has correct description for int64") + internal func invalidValueForTypeInt64ErrorDescription() { + let error = FieldParsingError.invalidValueForType("not-a-number", type: .int64) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Invalid value") == true) + #expect(description?.contains("not-a-number") == true) + #expect(description?.contains("int64") == true) + } + + @Test("invalidValueForType error has correct description for double") + internal func invalidValueForTypeDoubleErrorDescription() { + let error = FieldParsingError.invalidValueForType("not-a-number", type: .double) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Invalid value") == true) + #expect(description?.contains("not-a-number") == true) + #expect(description?.contains("double") == true) + } + + @Test("invalidValueForType error is thrown for invalid int64") + internal func invalidValueForTypeInt64ErrorThrown() { + do { + _ = try FieldType.int64.convertValue("not-a-number") + Issue.record("Expected invalidValueForType error to be thrown") + } catch let error as FieldParsingError { + if case .invalidValueForType = error { + // Success + } else { + Issue.record("Expected invalidValueForType error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + + @Test("invalidValueForType error is thrown for invalid double") + internal func invalidValueForTypeDoubleErrorThrown() { + do { + _ = try FieldType.double.convertValue("not-a-number") + Issue.record("Expected invalidValueForType error to be thrown") + } catch let error as FieldParsingError { + if case .invalidValueForType = error { + // Success + } else { + Issue.record("Expected invalidValueForType error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+UnknownFieldType.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+UnknownFieldType.swift new file mode 100644 index 00000000..05f4d57b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+UnknownFieldType.swift @@ -0,0 +1,66 @@ +// +// FieldParsingErrorTests+UnknownFieldType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldParsingErrorTests { + @Suite("unknownFieldType Error") + internal struct UnknownFieldType { + @Test("unknownFieldType error has correct description") + internal func unknownFieldTypeErrorDescription() { + let error = FieldParsingError.unknownFieldType("invalid", available: ["string", "int64"]) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Unknown field type") == true) + #expect(description?.contains("invalid") == true) + #expect(description?.contains("string") == true) + #expect(description?.contains("int64") == true) + } + + @Test("unknownFieldType error is thrown for invalid type") + internal func unknownFieldTypeErrorThrown() { + do { + _ = try Field(parsing: "name:invalid:value") + Issue.record("Expected unknownFieldType error to be thrown") + } catch let error as FieldParsingError { + if case .unknownFieldType = error { + // Success + } else { + Issue.record("Expected unknownFieldType error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+UnsupportedFieldType.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+UnsupportedFieldType.swift new file mode 100644 index 00000000..946c6ca5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests+UnsupportedFieldType.swift @@ -0,0 +1,90 @@ +// +// FieldParsingErrorTests+UnsupportedFieldType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldParsingErrorTests { + @Suite("unsupportedFieldType Error") + internal struct UnsupportedFieldType { + @Test("unsupportedFieldType error has correct description for asset") + internal func unsupportedFieldTypeAssetErrorDescription() { + let error = FieldParsingError.unsupportedFieldType(.asset) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("not yet supported") == true) + #expect(description?.contains("asset") == true) + } + + @Test("unsupportedFieldType error has correct description for location") + internal func unsupportedFieldTypeLocationErrorDescription() { + let error = FieldParsingError.unsupportedFieldType(.location) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("not yet supported") == true) + #expect(description?.contains("location") == true) + } + + @Test("unsupportedFieldType error is thrown for location type") + internal func unsupportedFieldTypeAssetErrorThrown() { + do { + _ = try FieldType.location.convertValue("anything") + Issue.record("Expected unsupportedFieldType error to be thrown") + } catch let error as FieldParsingError { + if case .unsupportedFieldType = error { + // Success + } else { + Issue.record("Expected unsupportedFieldType error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + + @Test("unsupportedFieldType error is thrown for bytes type") + internal func unsupportedFieldTypeBytesErrorThrown() { + do { + _ = try FieldType.bytes.convertValue("anything") + Issue.record("Expected unsupportedFieldType error to be thrown") + } catch let error as FieldParsingError { + if case .unsupportedFieldType = error { + // Success + } else { + Issue.record("Expected unsupportedFieldType error, got \(error)") + } + } catch { + Issue.record("Expected FieldParsingError, got \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests.swift new file mode 100644 index 00000000..0358867f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingError/FieldParsingErrorTests.swift @@ -0,0 +1,33 @@ +// +// FieldParsingErrorTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("FieldParsingError LocalizedError") +internal enum FieldParsingErrorTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingErrorTests.swift deleted file mode 100644 index 17ef95fe..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldParsingErrorTests.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// FieldParsingErrorTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("FieldParsingError LocalizedError Tests") -struct FieldParsingErrorTests { - - // MARK: - invalidFormat Error Tests - - @Test("invalidFormat error has correct description") - func invalidFormatErrorDescription() { - let error = FieldParsingError.invalidFormat("title:string", expected: "name:type:value") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Invalid field format") == true) - #expect(description?.contains("title:string") == true) - #expect(description?.contains("name:type:value") == true) - } - - @Test("invalidFormat error is thrown for missing parts") - func invalidFormatErrorThrown() { - do { - _ = try Field(parsing: "incomplete") - Issue.record("Expected invalidFormat error to be thrown") - } catch let error as FieldParsingError { - if case .invalidFormat = error { - // Success - } else { - Issue.record("Expected invalidFormat error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - // MARK: - emptyFieldName Error Tests - - @Test("emptyFieldName error has correct description") - func emptyFieldNameErrorDescription() { - let error = FieldParsingError.emptyFieldName(":string:value") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Empty field name") == true) - #expect(description?.contains(":string:value") == true) - } - - @Test("emptyFieldName error is thrown for empty name") - func emptyFieldNameErrorThrown() { - do { - _ = try Field(parsing: ":string:value") - Issue.record("Expected emptyFieldName error to be thrown") - } catch let error as FieldParsingError { - if case .emptyFieldName = error { - // Success - } else { - Issue.record("Expected emptyFieldName error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - @Test("emptyFieldName error is thrown for whitespace-only name") - func emptyFieldNameErrorThrownForWhitespace() { - do { - _ = try Field(parsing: " :string:value") - Issue.record("Expected emptyFieldName error to be thrown") - } catch let error as FieldParsingError { - if case .emptyFieldName = error { - // Success - } else { - Issue.record("Expected emptyFieldName error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - // MARK: - unknownFieldType Error Tests - - @Test("unknownFieldType error has correct description") - func unknownFieldTypeErrorDescription() { - let error = FieldParsingError.unknownFieldType("invalid", available: ["string", "int64"]) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Unknown field type") == true) - #expect(description?.contains("invalid") == true) - #expect(description?.contains("string") == true) - #expect(description?.contains("int64") == true) - } - - @Test("unknownFieldType error is thrown for invalid type") - func unknownFieldTypeErrorThrown() { - do { - _ = try Field(parsing: "name:invalid:value") - Issue.record("Expected unknownFieldType error to be thrown") - } catch let error as FieldParsingError { - if case .unknownFieldType = error { - // Success - } else { - Issue.record("Expected unknownFieldType error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - // MARK: - invalidValueForType Error Tests - - @Test("invalidValueForType error has correct description for int64") - func invalidValueForTypeInt64ErrorDescription() { - let error = FieldParsingError.invalidValueForType("not-a-number", type: .int64) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Invalid value") == true) - #expect(description?.contains("not-a-number") == true) - #expect(description?.contains("int64") == true) - } - - @Test("invalidValueForType error has correct description for double") - func invalidValueForTypeDoubleErrorDescription() { - let error = FieldParsingError.invalidValueForType("not-a-number", type: .double) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Invalid value") == true) - #expect(description?.contains("not-a-number") == true) - #expect(description?.contains("double") == true) - } - - @Test("invalidValueForType error is thrown for invalid int64") - func invalidValueForTypeInt64ErrorThrown() { - do { - _ = try FieldType.int64.convertValue("not-a-number") - Issue.record("Expected invalidValueForType error to be thrown") - } catch let error as FieldParsingError { - if case .invalidValueForType = error { - // Success - } else { - Issue.record("Expected invalidValueForType error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - @Test("invalidValueForType error is thrown for invalid double") - func invalidValueForTypeDoubleErrorThrown() { - do { - _ = try FieldType.double.convertValue("not-a-number") - Issue.record("Expected invalidValueForType error to be thrown") - } catch let error as FieldParsingError { - if case .invalidValueForType = error { - // Success - } else { - Issue.record("Expected invalidValueForType error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - // MARK: - unsupportedFieldType Error Tests - - @Test("unsupportedFieldType error has correct description for asset") - func unsupportedFieldTypeAssetErrorDescription() { - let error = FieldParsingError.unsupportedFieldType(.asset) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("not yet supported") == true) - #expect(description?.contains("asset") == true) - } - - @Test("unsupportedFieldType error has correct description for location") - func unsupportedFieldTypeLocationErrorDescription() { - let error = FieldParsingError.unsupportedFieldType(.location) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("not yet supported") == true) - #expect(description?.contains("location") == true) - } - - @Test("unsupportedFieldType error is thrown for location type") - func unsupportedFieldTypeAssetErrorThrown() { - do { - _ = try FieldType.location.convertValue("anything") - Issue.record("Expected unsupportedFieldType error to be thrown") - } catch let error as FieldParsingError { - if case .unsupportedFieldType = error { - // Success - } else { - Issue.record("Expected unsupportedFieldType error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } - - @Test("unsupportedFieldType error is thrown for bytes type") - func unsupportedFieldTypeBytesErrorThrown() { - do { - _ = try FieldType.bytes.convertValue("anything") - Issue.record("Expected unsupportedFieldType error to be thrown") - } catch let error as FieldParsingError { - if case .unsupportedFieldType = error { - // Success - } else { - Issue.record("Expected unsupportedFieldType error, got \(error)") - } - } catch { - Issue.record("Expected FieldParsingError, got \(error)") - } - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldTests.swift deleted file mode 100644 index 9944233b..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldTests.swift +++ /dev/null @@ -1,299 +0,0 @@ -// -// FieldTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("Field Parsing Tests") -struct FieldTests { - - // MARK: - Basic Parsing Tests - - @Test("Parse basic string field") - func parseBasicStringField() throws { - let field = try Field(parsing: "title:string:Hello World") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "Hello World") - } - - @Test("Parse int64 field") - func parseInt64Field() throws { - let field = try Field(parsing: "count:int64:42") - - #expect(field.name == "count") - #expect(field.type == .int64) - #expect(field.value == "42") - } - - @Test("Parse double field") - func parseDoubleField() throws { - let field = try Field(parsing: "price:double:19.99") - - #expect(field.name == "price") - #expect(field.type == .double) - #expect(field.value == "19.99") - } - - @Test("Parse timestamp field") - func parseTimestampField() throws { - let field = try Field(parsing: "createdAt:timestamp:2024-01-15T10:30:00Z") - - #expect(field.name == "createdAt") - #expect(field.type == .timestamp) - #expect(field.value == "2024-01-15T10:30:00Z") - } - - // MARK: - Colon Handling Tests - - @Test("Parse field with colons in value (URL)") - func parseFieldWithColonsInURL() throws { - let field = try Field(parsing: "url:string:https://example.com:8080/path") - - #expect(field.name == "url") - #expect(field.type == .string) - #expect(field.value == "https://example.com:8080/path") - } - - @Test("Parse field with colons in value (time)") - func parseFieldWithColonsInTime() throws { - let field = try Field(parsing: "time:string:10:30:45") - - #expect(field.name == "time") - #expect(field.type == .string) - #expect(field.value == "10:30:45") - } - - @Test("Parse field with multiple colons in value") - func parseFieldWithMultipleColons() throws { - let field = try Field(parsing: "data:string:a:b:c:d:e") - - #expect(field.name == "data") - #expect(field.type == .string) - #expect(field.value == "a:b:c:d:e") - } - - // MARK: - Whitespace Handling Tests - - @Test("Parse field with leading/trailing whitespace in name") - func parseFieldWithWhitespaceInName() throws { - let field = try Field(parsing: " title :string:value") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "value") - } - - @Test("Parse field with leading/trailing whitespace in type") - func parseFieldWithWhitespaceInType() throws { - let field = try Field(parsing: "title: string :value") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "value") - } - - @Test("Parse field preserving whitespace in value") - func parseFieldPreservingWhitespaceInValue() throws { - let field = try Field(parsing: "title:string: Hello World ") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == " Hello World ") - } - - @Test("Parse field with only whitespace in value") - func parseFieldWithOnlyWhitespaceInValue() throws { - let field = try Field(parsing: "title:string: ") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == " ") - } - - // MARK: - Edge Cases - - @Test("Parse field with empty value") - func parseFieldWithEmptyValue() throws { - let field = try Field(parsing: "title:string:") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "") - } - - @Test("Parse field with Unicode in value") - func parseFieldWithUnicode() throws { - let field = try Field(parsing: "message:string:こんにちは世界") - - #expect(field.name == "message") - #expect(field.type == .string) - #expect(field.value == "こんにちは世界") - } - - @Test("Parse field with emoji in value") - func parseFieldWithEmoji() throws { - let field = try Field(parsing: "reaction:string:👍🎉🚀") - - #expect(field.name == "reaction") - #expect(field.type == .string) - #expect(field.value == "👍🎉🚀") - } - - @Test("Parse field with special characters in value") - func parseFieldWithSpecialCharacters() throws { - let field = try Field(parsing: "data:string:!@#$%^&*()_+-=[]{}|;'\"<>,.?/~`") - - #expect(field.name == "data") - #expect(field.type == .string) - #expect(field.value == "!@#$%^&*()_+-=[]{}|;'\"<>,.?/~`") - } - - @Test("Parse field with newline in value") - func parseFieldWithNewlineInValue() throws { - let field = try Field(parsing: "text:string:line1\nline2") - - #expect(field.name == "text") - #expect(field.type == .string) - #expect(field.value == "line1\nline2") - } - - @Test("Parse field with tab in value") - func parseFieldWithTabInValue() throws { - let field = try Field(parsing: "text:string:col1\tcol2") - - #expect(field.name == "text") - #expect(field.type == .string) - #expect(field.value == "col1\tcol2") - } - - // MARK: - Case Sensitivity Tests - - @Test("Parse field with uppercase type (normalized to lowercase)") - func parseFieldWithUppercaseType() throws { - let field = try Field(parsing: "title:STRING:value") - - #expect(field.name == "title") - #expect(field.type == .string) - #expect(field.value == "value") - } - - @Test("Parse field with mixed case type") - func parseFieldWithMixedCaseType() throws { - let field = try Field(parsing: "count:InT64:42") - - #expect(field.name == "count") - #expect(field.type == .int64) - #expect(field.value == "42") - } - - // MARK: - Error Cases - - @Test("Parse field with empty name throws error") - func parseFieldWithEmptyName() { - #expect(throws: FieldParsingError.self) { - try Field(parsing: ":string:value") - } - } - - @Test("Parse field with whitespace-only name throws error") - func parseFieldWithWhitespaceOnlyName() { - #expect(throws: FieldParsingError.self) { - try Field(parsing: " :string:value") - } - } - - @Test("Parse field with unknown type throws error") - func parseFieldWithUnknownType() { - #expect(throws: FieldParsingError.self) { - try Field(parsing: "title:unknown:value") - } - } - - @Test("Parse field with invalid format (too few parts)") - func parseFieldWithTooFewParts() { - #expect(throws: FieldParsingError.self) { - try Field(parsing: "title:string") - } - } - - @Test("Parse field with invalid format (one part)") - func parseFieldWithOnePart() { - #expect(throws: FieldParsingError.self) { - try Field(parsing: "title") - } - } - - @Test("Parse field with invalid format (empty string)") - func parseFieldWithEmptyString() { - #expect(throws: FieldParsingError.self) { - try Field(parsing: "") - } - } - - // MARK: - parseMultiple Tests - - @Test("Parse multiple valid fields") - func parseMultipleValidFields() throws { - let inputs = [ - "title:string:Hello", - "count:int64:42", - "price:double:19.99" - ] - - let fields = try Field.parseMultiple(inputs) - - #expect(fields.count == 3) - #expect(fields[0].name == "title") - #expect(fields[1].name == "count") - #expect(fields[2].name == "price") - } - - @Test("Parse multiple fields with empty array") - func parseMultipleFieldsWithEmptyArray() throws { - let fields = try Field.parseMultiple([]) - - #expect(fields.isEmpty) - } - - @Test("Parse multiple fields throws on first invalid") - func parseMultipleFieldsThrowsOnInvalid() { - let inputs = [ - "title:string:Hello", - "invalid", - "price:double:19.99" - ] - - #expect(throws: FieldParsingError.self) { - try Field.parseMultiple(inputs) - } - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+DoubleConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+DoubleConversion.swift new file mode 100644 index 00000000..c58c245c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+DoubleConversion.swift @@ -0,0 +1,94 @@ +// +// FieldTypeTests+DoubleConversion.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTypeTests { + @Suite("Double Conversion") + internal struct DoubleConversion { + @Test("Convert valid positive double") + internal func convertValidPositiveDouble() throws { + let value = try FieldType.double.convertValue("19.99") + + #expect(value as? Double == 19.99) + } + + @Test("Convert valid negative double") + internal func convertValidNegativeDouble() throws { + let value = try FieldType.double.convertValue("-3.14") + + #expect(value as? Double == -3.14) + } + + @Test("Convert double zero") + internal func convertDoubleZero() throws { + let value = try FieldType.double.convertValue("0.0") + + #expect(value as? Double == 0.0) + } + + @Test("Convert double integer value") + internal func convertDoubleIntegerValue() throws { + let value = try FieldType.double.convertValue("42") + + #expect(value as? Double == 42.0) + } + + @Test("Convert double scientific notation") + internal func convertDoubleScientificNotation() throws { + let value = try FieldType.double.convertValue("1.5e10") + + #expect(value as? Double == 1.5e10) + } + + @Test("Convert double negative scientific notation") + internal func convertDoubleNegativeScientificNotation() throws { + let value = try FieldType.double.convertValue("3.14e-5") + + #expect(value as? Double == 3.14e-5) + } + + @Test("Convert invalid double (non-numeric) throws error") + internal func convertInvalidDoubleNonNumeric() { + #expect(throws: FieldParsingError.self) { + try FieldType.double.convertValue("not a number") + } + } + + @Test("Convert invalid double (empty) throws error") + internal func convertInvalidDoubleEmpty() { + #expect(throws: FieldParsingError.self) { + try FieldType.double.convertValue("") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+EnumProperties.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+EnumProperties.swift new file mode 100644 index 00000000..5afa6f72 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+EnumProperties.swift @@ -0,0 +1,80 @@ +// +// FieldTypeTests+EnumProperties.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTypeTests { + @Suite("Enum Properties") + internal struct EnumProperties { + @Test("FieldType has all expected cases") + internal func fieldTypeAllCases() { + let allCases = FieldType.allCases + + #expect(allCases.contains(.string)) + #expect(allCases.contains(.int64)) + #expect(allCases.contains(.double)) + #expect(allCases.contains(.timestamp)) + #expect(allCases.contains(.asset)) + #expect(allCases.contains(.location)) + #expect(allCases.contains(.reference)) + #expect(allCases.contains(.bytes)) + #expect(allCases.count == 8) + } + + @Test("FieldType raw values are correct") + internal func fieldTypeRawValues() { + #expect(FieldType.string.rawValue == "string") + #expect(FieldType.int64.rawValue == "int64") + #expect(FieldType.double.rawValue == "double") + #expect(FieldType.timestamp.rawValue == "timestamp") + #expect(FieldType.asset.rawValue == "asset") + #expect(FieldType.location.rawValue == "location") + #expect(FieldType.reference.rawValue == "reference") + #expect(FieldType.bytes.rawValue == "bytes") + } + + @Test("FieldType can be initialized from raw value") + internal func fieldTypeInitFromRawValue() { + #expect(FieldType(rawValue: "string") == .string) + #expect(FieldType(rawValue: "int64") == .int64) + #expect(FieldType(rawValue: "double") == .double) + #expect(FieldType(rawValue: "timestamp") == .timestamp) + } + + @Test("FieldType returns nil for invalid raw value") + internal func fieldTypeNilForInvalidRawValue() { + #expect(FieldType(rawValue: "invalid") == nil) + #expect(FieldType(rawValue: "STRING") == nil) // case-sensitive + #expect(FieldType(rawValue: "") == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+Int64Conversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+Int64Conversion.swift new file mode 100644 index 00000000..19b3e899 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+Int64Conversion.swift @@ -0,0 +1,94 @@ +// +// FieldTypeTests+Int64Conversion.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTypeTests { + @Suite("Int64 Conversion") + internal struct Int64Conversion { + @Test("Convert valid positive int64") + internal func convertValidPositiveInt64() throws { + let value = try FieldType.int64.convertValue("42") + + #expect(value as? Int64 == 42) + } + + @Test("Convert valid negative int64") + internal func convertValidNegativeInt64() throws { + let value = try FieldType.int64.convertValue("-123") + + #expect(value as? Int64 == -123) + } + + @Test("Convert int64 zero") + internal func convertInt64Zero() throws { + let value = try FieldType.int64.convertValue("0") + + #expect(value as? Int64 == 0) + } + + @Test("Convert int64 maximum value") + internal func convertInt64MaxValue() throws { + let value = try FieldType.int64.convertValue("9223372036854775807") + + #expect(value as? Int64 == Int64.max) + } + + @Test("Convert int64 minimum value") + internal func convertInt64MinValue() throws { + let value = try FieldType.int64.convertValue("-9223372036854775808") + + #expect(value as? Int64 == Int64.min) + } + + @Test("Convert invalid int64 (non-numeric) throws error") + internal func convertInvalidInt64NonNumeric() { + #expect(throws: FieldParsingError.self) { + try FieldType.int64.convertValue("not a number") + } + } + + @Test("Convert invalid int64 (decimal) throws error") + internal func convertInvalidInt64Decimal() { + #expect(throws: FieldParsingError.self) { + try FieldType.int64.convertValue("42.5") + } + } + + @Test("Convert invalid int64 (empty) throws error") + internal func convertInvalidInt64Empty() { + #expect(throws: FieldParsingError.self) { + try FieldType.int64.convertValue("") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+StringConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+StringConversion.swift new file mode 100644 index 00000000..ee50995e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+StringConversion.swift @@ -0,0 +1,59 @@ +// +// FieldTypeTests+StringConversion.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTypeTests { + @Suite("String Conversion") + internal struct StringConversion { + @Test("Convert string value (always succeeds)") + internal func convertStringValue() throws { + let value = try FieldType.string.convertValue("Hello World") + + #expect(value as? String == "Hello World") + } + + @Test("Convert empty string value") + internal func convertEmptyStringValue() throws { + let value = try FieldType.string.convertValue("") + + #expect((value as? String)?.isEmpty == true) + } + + @Test("Convert string with special characters") + internal func convertStringWithSpecialCharacters() throws { + let value = try FieldType.string.convertValue("!@#$%^&*()") + + #expect(value as? String == "!@#$%^&*()") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+TimestampConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+TimestampConversion.swift new file mode 100644 index 00000000..ba648c28 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+TimestampConversion.swift @@ -0,0 +1,99 @@ +// +// FieldTypeTests+TimestampConversion.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTypeTests { + @Suite("Timestamp Conversion") + internal struct TimestampConversion { + @Test("Convert timestamp from ISO 8601 date") + internal func convertTimestampFromISO8601() throws { + let value = try FieldType.timestamp.convertValue("2024-01-15T10:30:00Z") + + #expect(value is Date) + let date = value as? Date + #expect(date != nil) + } + + @Test("Convert timestamp from ISO 8601 with timezone") + internal func convertTimestampFromISO8601WithTimezone() throws { + let value = try FieldType.timestamp.convertValue("2024-01-15T10:30:00+05:00") + + #expect(value is Date) + } + + @Test("Convert timestamp from Unix timestamp (integer)") + internal func convertTimestampFromUnixInteger() throws { + let value = try FieldType.timestamp.convertValue("1705315800") + + let date = value as? Date + #expect(date?.timeIntervalSince1970 == 1_705_315_800.0) + } + + @Test("Convert timestamp from Unix timestamp (decimal)") + internal func convertTimestampFromUnixDecimal() throws { + let value = try FieldType.timestamp.convertValue("1705315800.5") + + let date = value as? Date + #expect(date?.timeIntervalSince1970 == 1_705_315_800.5) + } + + @Test("Convert timestamp from zero (epoch)") + internal func convertTimestampFromZero() throws { + let value = try FieldType.timestamp.convertValue("0") + + let date = value as? Date + #expect(date?.timeIntervalSince1970 == 0.0) + } + + @Test("Convert invalid timestamp (non-date string) throws error") + internal func convertInvalidTimestampNonDate() { + #expect(throws: FieldParsingError.self) { + try FieldType.timestamp.convertValue("not a date") + } + } + + @Test("Convert invalid timestamp (empty) throws error") + internal func convertInvalidTimestampEmpty() { + #expect(throws: FieldParsingError.self) { + try FieldType.timestamp.convertValue("") + } + } + + @Test("Convert invalid timestamp (invalid ISO format) throws error") + internal func convertInvalidTimestampInvalidISO() { + #expect(throws: FieldParsingError.self) { + try FieldType.timestamp.convertValue("2024-13-45T99:99:99Z") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+UnsupportedType.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+UnsupportedType.swift new file mode 100644 index 00000000..e05de3ca --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests+UnsupportedType.swift @@ -0,0 +1,66 @@ +// +// FieldTypeTests+UnsupportedType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldTypeTests { + @Suite("Unsupported Type") + internal struct UnsupportedType { + @Test("Convert asset type returns URL string") + internal func convertAssetThrowsUnsupported() throws { + let value = try FieldType.asset.convertValue("https://example.com/asset") + + #expect(value as? String == "https://example.com/asset") + } + + @Test("Convert location type throws unsupported error") + internal func convertLocationThrowsUnsupported() { + #expect(throws: FieldParsingError.self) { + try FieldType.location.convertValue("anything") + } + } + + @Test("Convert reference type throws unsupported error") + internal func convertReferenceThrowsUnsupported() { + #expect(throws: FieldParsingError.self) { + try FieldType.reference.convertValue("anything") + } + } + + @Test("Convert bytes type throws unsupported error") + internal func convertBytesThrowsUnsupported() { + #expect(throws: FieldParsingError.self) { + try FieldType.bytes.convertValue("anything") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests.swift new file mode 100644 index 00000000..25a4d442 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldType/FieldTypeTests.swift @@ -0,0 +1,33 @@ +// +// FieldTypeTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("FieldType Conversion") +internal enum FieldTypeTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldTypeTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldTypeTests.swift deleted file mode 100644 index 21c237a6..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FieldTypeTests.swift +++ /dev/null @@ -1,312 +0,0 @@ -// -// FieldTypeTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("FieldType Conversion Tests") -struct FieldTypeTests { - - // MARK: - String Conversion Tests - - @Test("Convert string value (always succeeds)") - func convertStringValue() throws { - let value = try FieldType.string.convertValue("Hello World") - - #expect(value as? String == "Hello World") - } - - @Test("Convert empty string value") - func convertEmptyStringValue() throws { - let value = try FieldType.string.convertValue("") - - #expect(value as? String == "") - } - - @Test("Convert string with special characters") - func convertStringWithSpecialCharacters() throws { - let value = try FieldType.string.convertValue("!@#$%^&*()") - - #expect(value as? String == "!@#$%^&*()") - } - - // MARK: - Int64 Conversion Tests - - @Test("Convert valid positive int64") - func convertValidPositiveInt64() throws { - let value = try FieldType.int64.convertValue("42") - - #expect(value as? Int64 == 42) - } - - @Test("Convert valid negative int64") - func convertValidNegativeInt64() throws { - let value = try FieldType.int64.convertValue("-123") - - #expect(value as? Int64 == -123) - } - - @Test("Convert int64 zero") - func convertInt64Zero() throws { - let value = try FieldType.int64.convertValue("0") - - #expect(value as? Int64 == 0) - } - - @Test("Convert int64 maximum value") - func convertInt64MaxValue() throws { - let value = try FieldType.int64.convertValue("9223372036854775807") - - #expect(value as? Int64 == Int64.max) - } - - @Test("Convert int64 minimum value") - func convertInt64MinValue() throws { - let value = try FieldType.int64.convertValue("-9223372036854775808") - - #expect(value as? Int64 == Int64.min) - } - - @Test("Convert invalid int64 (non-numeric) throws error") - func convertInvalidInt64NonNumeric() { - #expect(throws: FieldParsingError.self) { - try FieldType.int64.convertValue("not a number") - } - } - - @Test("Convert invalid int64 (decimal) throws error") - func convertInvalidInt64Decimal() { - #expect(throws: FieldParsingError.self) { - try FieldType.int64.convertValue("42.5") - } - } - - @Test("Convert invalid int64 (empty) throws error") - func convertInvalidInt64Empty() { - #expect(throws: FieldParsingError.self) { - try FieldType.int64.convertValue("") - } - } - - // MARK: - Double Conversion Tests - - @Test("Convert valid positive double") - func convertValidPositiveDouble() throws { - let value = try FieldType.double.convertValue("19.99") - - #expect(value as? Double == 19.99) - } - - @Test("Convert valid negative double") - func convertValidNegativeDouble() throws { - let value = try FieldType.double.convertValue("-3.14") - - #expect(value as? Double == -3.14) - } - - @Test("Convert double zero") - func convertDoubleZero() throws { - let value = try FieldType.double.convertValue("0.0") - - #expect(value as? Double == 0.0) - } - - @Test("Convert double integer value") - func convertDoubleIntegerValue() throws { - let value = try FieldType.double.convertValue("42") - - #expect(value as? Double == 42.0) - } - - @Test("Convert double scientific notation") - func convertDoubleScientificNotation() throws { - let value = try FieldType.double.convertValue("1.5e10") - - #expect(value as? Double == 1.5e10) - } - - @Test("Convert double negative scientific notation") - func convertDoubleNegativeScientificNotation() throws { - let value = try FieldType.double.convertValue("3.14e-5") - - #expect(value as? Double == 3.14e-5) - } - - @Test("Convert invalid double (non-numeric) throws error") - func convertInvalidDoubleNonNumeric() { - #expect(throws: FieldParsingError.self) { - try FieldType.double.convertValue("not a number") - } - } - - @Test("Convert invalid double (empty) throws error") - func convertInvalidDoubleEmpty() { - #expect(throws: FieldParsingError.self) { - try FieldType.double.convertValue("") - } - } - - // MARK: - Timestamp Conversion Tests - - @Test("Convert timestamp from ISO 8601 date") - func convertTimestampFromISO8601() throws { - let value = try FieldType.timestamp.convertValue("2024-01-15T10:30:00Z") - - #expect(value is Date) - let date = value as? Date - #expect(date != nil) - } - - @Test("Convert timestamp from ISO 8601 with timezone") - func convertTimestampFromISO8601WithTimezone() throws { - let value = try FieldType.timestamp.convertValue("2024-01-15T10:30:00+05:00") - - #expect(value is Date) - } - - @Test("Convert timestamp from Unix timestamp (integer)") - func convertTimestampFromUnixInteger() throws { - let value = try FieldType.timestamp.convertValue("1705315800") - - let date = value as? Date - #expect(date?.timeIntervalSince1970 == 1705315800.0) - } - - @Test("Convert timestamp from Unix timestamp (decimal)") - func convertTimestampFromUnixDecimal() throws { - let value = try FieldType.timestamp.convertValue("1705315800.5") - - let date = value as? Date - #expect(date?.timeIntervalSince1970 == 1705315800.5) - } - - @Test("Convert timestamp from zero (epoch)") - func convertTimestampFromZero() throws { - let value = try FieldType.timestamp.convertValue("0") - - let date = value as? Date - #expect(date?.timeIntervalSince1970 == 0.0) - } - - @Test("Convert invalid timestamp (non-date string) throws error") - func convertInvalidTimestampNonDate() { - #expect(throws: FieldParsingError.self) { - try FieldType.timestamp.convertValue("not a date") - } - } - - @Test("Convert invalid timestamp (empty) throws error") - func convertInvalidTimestampEmpty() { - #expect(throws: FieldParsingError.self) { - try FieldType.timestamp.convertValue("") - } - } - - @Test("Convert invalid timestamp (invalid ISO format) throws error") - func convertInvalidTimestampInvalidISO() { - #expect(throws: FieldParsingError.self) { - try FieldType.timestamp.convertValue("2024-13-45T99:99:99Z") - } - } - - // MARK: - Unsupported Type Tests - - @Test("Convert asset type returns URL string") - func convertAssetThrowsUnsupported() throws { - let value = try FieldType.asset.convertValue("https://example.com/asset") - - #expect(value as? String == "https://example.com/asset") - } - - @Test("Convert location type throws unsupported error") - func convertLocationThrowsUnsupported() { - #expect(throws: FieldParsingError.self) { - try FieldType.location.convertValue("anything") - } - } - - @Test("Convert reference type throws unsupported error") - func convertReferenceThrowsUnsupported() { - #expect(throws: FieldParsingError.self) { - try FieldType.reference.convertValue("anything") - } - } - - @Test("Convert bytes type throws unsupported error") - func convertBytesThrowsUnsupported() { - #expect(throws: FieldParsingError.self) { - try FieldType.bytes.convertValue("anything") - } - } - - // MARK: - Enum Properties Tests - - @Test("FieldType has all expected cases") - func fieldTypeAllCases() { - let allCases = FieldType.allCases - - #expect(allCases.contains(.string)) - #expect(allCases.contains(.int64)) - #expect(allCases.contains(.double)) - #expect(allCases.contains(.timestamp)) - #expect(allCases.contains(.asset)) - #expect(allCases.contains(.location)) - #expect(allCases.contains(.reference)) - #expect(allCases.contains(.bytes)) - #expect(allCases.count == 8) - } - - @Test("FieldType raw values are correct") - func fieldTypeRawValues() { - #expect(FieldType.string.rawValue == "string") - #expect(FieldType.int64.rawValue == "int64") - #expect(FieldType.double.rawValue == "double") - #expect(FieldType.timestamp.rawValue == "timestamp") - #expect(FieldType.asset.rawValue == "asset") - #expect(FieldType.location.rawValue == "location") - #expect(FieldType.reference.rawValue == "reference") - #expect(FieldType.bytes.rawValue == "bytes") - } - - @Test("FieldType can be initialized from raw value") - func fieldTypeInitFromRawValue() { - #expect(FieldType(rawValue: "string") == .string) - #expect(FieldType(rawValue: "int64") == .int64) - #expect(FieldType(rawValue: "double") == .double) - #expect(FieldType(rawValue: "timestamp") == .timestamp) - } - - @Test("FieldType returns nil for invalid raw value") - func fieldTypeNilForInvalidRawValue() { - #expect(FieldType(rawValue: "invalid") == nil) - #expect(FieldType(rawValue: "STRING") == nil) // case-sensitive - #expect(FieldType(rawValue: "") == nil) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupConfigTests.swift new file mode 100644 index 00000000..80e16cbd --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupConfigTests.swift @@ -0,0 +1,102 @@ +// +// LookupConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("LookupConfig Tests") +internal struct LookupConfigTests { + @Test("LookupConfig initializes with a single record name") + internal func singleRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig(base: baseConfig, recordNames: ["rec-1"]) + + #expect(config.recordNames == ["rec-1"]) + #expect(config.fields == nil) + #expect(config.output == .json) + } + + @Test("LookupConfig initializes with multiple record names") + internal func multipleRecordNames() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig(base: baseConfig, recordNames: ["a", "b", "c"]) + + #expect(config.recordNames == ["a", "b", "c"]) + } + + @Test("LookupConfig initializes with explicit fields filter") + internal func explicitFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig( + base: baseConfig, + recordNames: ["rec-1"], + fields: ["title", "priority"] + ) + + #expect(config.fields == ["title", "priority"]) + } + + @Test("LookupConfig fields is nil when not provided") + internal func fieldsDefaultNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig(base: baseConfig, recordNames: ["rec-1"]) + + #expect(config.fields == nil) + } + + @Test( + "LookupConfig output formats round-trip", arguments: [OutputFormat.json, .table, .csv, .yaml]) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig(base: baseConfig, recordNames: ["rec-1"], output: format) + + #expect(config.output == format) + } + + @Test("LookupConfig preserves order of record names") + internal func preservesOrder() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupConfig(base: baseConfig, recordNames: ["z", "a", "m"]) + + #expect(config.recordNames == ["z", "a", "m"]) + } + + @Test("LookupConfig handles many record names") + internal func manyRecordNames() async throws { + let baseConfig = try await MistDemoConfig() + let names = (0..<50).map { "rec-\($0)" } + let config = LookupConfig(base: baseConfig, recordNames: names) + + #expect(config.recordNames.count == 50) + #expect(config.recordNames.first == "rec-0") + #expect(config.recordNames.last == "rec-49") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupErrorTests.swift new file mode 100644 index 00000000..3ae0dc22 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupErrorTests.swift @@ -0,0 +1,53 @@ +// +// LookupErrorTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("LookupError Tests") +internal struct LookupErrorTests { + @Test("recordNamesRequired has a description") + internal func recordNamesRequiredDescription() { + let error = LookupError.recordNamesRequired + #expect(error.errorDescription != nil) + } + + @Test("recordNamesRequired suggests using --record-names") + internal func recordNamesRequiredSuggestion() { + let error = LookupError.recordNamesRequired + #expect(error.recoverySuggestion?.contains("record-names") == true) + } + + @Test("operationFailed wraps the underlying reason") + internal func operationFailedWrapsReason() { + let error = LookupError.operationFailed("some failure") + #expect(error.errorDescription?.contains("some failure") == true) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupZonesConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupZonesConfigTests.swift new file mode 100644 index 00000000..4cceae3c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/LookupZonesConfigTests.swift @@ -0,0 +1,68 @@ +// +// LookupZonesConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("LookupZonesConfig Tests") +internal struct LookupZonesConfigTests { + @Test("LookupZonesConfig initializes with a single zone name") + internal func singleZoneName() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupZonesConfig(base: baseConfig, zoneNames: ["_defaultZone"]) + + #expect(config.zoneNames == ["_defaultZone"]) + #expect(config.output == .table) + } + + @Test("LookupZonesConfig initializes with multiple zone names preserving order") + internal func multipleZoneNames() async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupZonesConfig(base: baseConfig, zoneNames: ["zone-z", "zone-a", "zone-m"]) + + #expect(config.zoneNames == ["zone-z", "zone-a", "zone-m"]) + } + + @Test( + "LookupZonesConfig output formats round-trip", + arguments: [OutputFormat.json, .table, .csv, .yaml] + ) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = LookupZonesConfig( + base: baseConfig, + zoneNames: ["_defaultZone"], + output: format + ) + + #expect(config.output == format) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigTests.swift index 3b7b0889..a360b232 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigTests.swift @@ -31,20 +31,20 @@ import Foundation import MistKit import Testing -@testable import MistDemo +@testable import MistDemoKit @Suite("MistDemoConfig Tests") -struct MistDemoConfigTests { +internal struct MistDemoConfigTests { // MARK: - Default Values Tests @Test("Config initializes with default values") - func configInitializesWithDefaults() async throws { + internal func configInitializesWithDefaults() async throws { let config = try await MistDemoConfig() #expect(config.containerIdentifier == "iCloud.com.brightdigit.MistDemo") #expect(config.environment == .development) #expect(config.host == "127.0.0.1") - #expect(config.port == 8080) + #expect(config.port == 8_080) #expect(config.skipAuth == false) #expect(config.testAllAuth == false) #expect(config.testApiOnly == false) @@ -55,7 +55,7 @@ struct MistDemoConfigTests { // MARK: - Public API Tests @Test("Config properties are accessible") - func configPropertiesAccessible() async throws { + internal func configPropertiesAccessible() async throws { let config = try await MistDemoConfig() // Verify all properties can be read @@ -75,33 +75,46 @@ struct MistDemoConfigTests { _ = config.testServerToServer } - // MARK: - Environment Tests @Test("Development environment is default") - func developmentEnvironmentIsDefault() async throws { + internal func developmentEnvironmentIsDefault() async throws { let config = try await MistDemoConfig() #expect(config.environment == .development) } + @Test("Invalid environment surfaces as ConfigurationError.invalidEnvironment") + internal func invalidEnvironmentThrows() async throws { + do { + _ = try await MistDemoConfig(rawEnvironment: "staging") + Issue.record("Expected ConfigurationError.invalidEnvironment") + } catch let error as ConfigurationError { + if case .invalidEnvironment(let raw) = error { + #expect(raw == "staging") + } else { + Issue.record("Wrong ConfigurationError case: \(error)") + } + } + } + // MARK: - Server Configuration Tests @Test("Default host is localhost") - func defaultHostIsLocalhost() async throws { + internal func defaultHostIsLocalhost() async throws { let config = try await MistDemoConfig() #expect(config.host == "127.0.0.1") } @Test("Default port is 8080") - func defaultPortIs8080() async throws { + internal func defaultPortIs8080() async throws { let config = try await MistDemoConfig() - #expect(config.port == 8080) + #expect(config.port == 8_080) } // MARK: - Test Flags Tests @Test("All test flags default to false") - func allTestFlagsDefaultToFalse() async throws { + internal func allTestFlagsDefaultToFalse() async throws { let config = try await MistDemoConfig() #expect(config.skipAuth == false) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigParsingTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigParsingTests.swift new file mode 100644 index 00000000..bc5c2adb --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigParsingTests.swift @@ -0,0 +1,132 @@ +// +// ModifyConfigParsingTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("ModifyConfig JSON Parsing Tests") +internal struct ModifyConfigParsingTests { + @Test("Parses a single create operation") + internal func parseCreate() throws { + let json = """ + [ + {"op":"create","recordType":"Note","fields":{"title":"Hello","priority":5}} + ] + """ + let data = Data(json.utf8) + let ops = try ModifyConfig.parseOperations(from: data) + + #expect(ops.count == 1) + #expect(ops[0].operation == .create) + #expect(ops[0].recordType == "Note") + #expect(ops[0].recordName == nil) + #expect(ops[0].fields != nil) + } + + @Test("Parses an update operation with change tag") + internal func parseUpdate() throws { + let json = """ + [ + { + "op":"update", + "recordType":"Note", + "recordName":"note-1", + "recordChangeTag":"abc", + "fields":{"title":"x"} + } + ] + """ + let data = Data(json.utf8) + let ops = try ModifyConfig.parseOperations(from: data) + + #expect(ops.count == 1) + #expect(ops[0].operation == .update) + #expect(ops[0].recordName == "note-1") + #expect(ops[0].recordChangeTag == "abc") + } + + @Test("Parses a delete operation") + internal func parseDelete() throws { + let json = """ + [ + {"op":"delete","recordType":"Note","recordName":"note-1"} + ] + """ + let data = Data(json.utf8) + let ops = try ModifyConfig.parseOperations(from: data) + + #expect(ops.count == 1) + #expect(ops[0].operation == .delete) + #expect(ops[0].recordName == "note-1") + } + + @Test("Parses a mixed batch") + internal func parseMixedBatch() throws { + let json = """ + [ + {"op":"create","recordType":"Note","fields":{"title":"A"}}, + {"op":"update","recordType":"Note","recordName":"n1","fields":{"title":"B"}}, + {"op":"delete","recordType":"Note","recordName":"n2"} + ] + """ + let data = Data(json.utf8) + let ops = try ModifyConfig.parseOperations(from: data) + + #expect(ops.count == 3) + #expect(ops[0].operation == .create) + #expect(ops[1].operation == .update) + #expect(ops[2].operation == .delete) + } + + @Test("Rejects an unknown op") + internal func rejectsUnknownOp() throws { + let json = """ + [ + {"op":"frobnicate","recordType":"Note"} + ] + """ + let data = Data(json.utf8) + + #expect(throws: ModifyError.self) { + _ = try ModifyConfig.parseOperations(from: data) + } + } + + @Test("Rejects malformed JSON") + internal func rejectsMalformedJSON() throws { + let json = "not even json" + let data = Data(json.utf8) + + #expect(throws: ModifyError.self) { + _ = try ModifyConfig.parseOperations(from: data) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift new file mode 100644 index 00000000..280b87bb --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift @@ -0,0 +1,70 @@ +// +// ModifyConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("ModifyConfig Tests") +internal struct ModifyConfigTests { + @Test("ModifyConfig initializes with empty operations") + internal func emptyOperations() async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig(base: baseConfig, operations: []) + + #expect(config.operations.isEmpty) + #expect(config.atomic == false) + #expect(config.output == .json) + } + + @Test("ModifyConfig defaults atomic to false") + internal func atomicDefaultsFalse() async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig(base: baseConfig, operations: []) + + #expect(config.atomic == false) + } + + @Test("ModifyConfig accepts atomic=true") + internal func atomicCanBeTrue() async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig(base: baseConfig, operations: [], atomic: true) + + #expect(config.atomic == true) + } + + @Test( + "ModifyConfig output formats round-trip", arguments: [OutputFormat.json, .table, .csv, .yaml]) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig(base: baseConfig, operations: [], output: format) + + #expect(config.output == format) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyErrorTests.swift new file mode 100644 index 00000000..0d027eb0 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyErrorTests.swift @@ -0,0 +1,55 @@ +// +// ModifyErrorTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("ModifyError Tests") +internal struct ModifyErrorTests { + @Test("operationsRequired has a description") + internal func operationsRequiredDescription() { + #expect(ModifyError.operationsRequired.errorDescription != nil) + } + + @Test("missingRecordName description includes index and op") + internal func missingRecordNameDescription() { + let error = ModifyError.missingRecordName(opIndex: 2, operation: "update") + let description = error.errorDescription ?? "" + + #expect(description.contains("2")) + #expect(description.contains("update")) + } + + @Test("invalidOperationType description includes the op") + internal func invalidOperationTypeDescription() { + let error = ModifyError.invalidOperationType("frobnicate") + #expect(error.errorDescription?.contains("frobnicate") == true) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyOperationInputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyOperationInputTests.swift new file mode 100644 index 00000000..b3503379 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyOperationInputTests.swift @@ -0,0 +1,63 @@ +// +// ModifyOperationInputTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import MistKit +import Testing + +@testable import MistDemoKit + +@Suite("ModifyOperationInput Validation Tests") +internal struct ModifyOperationInputTests { + @Test("update requires a recordName") + internal func updateRequiresRecordName() throws { + let input = ModifyOperationInput(operation: .update, recordType: "Note", recordName: nil) + + #expect(throws: ModifyError.self) { + _ = try input.toRecordOperation(index: 0) + } + } + + @Test("delete requires a recordName") + internal func deleteRequiresRecordName() throws { + let input = ModifyOperationInput(operation: .delete, recordType: "Note", recordName: nil) + + #expect(throws: ModifyError.self) { + _ = try input.toRecordOperation(index: 0) + } + } + + @Test("create succeeds without a recordName") + internal func createWithoutRecordName() throws { + let input = ModifyOperationInput(operation: .create, recordType: "Note", recordName: nil) + let operation = try input.toRecordOperation(index: 0) + + #expect(operation.recordName == nil) + #expect(operation.recordType == "Note") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift new file mode 100644 index 00000000..c5109eef --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift @@ -0,0 +1,79 @@ +// +// QueryConfigTests+BasicInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Basic Initialization") + internal struct BasicInitialization { + @Test("QueryConfig initializes with default values") + internal func initializeWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Note") + #expect(config.filters.isEmpty) + #expect(config.sort == nil) + #expect(config.limit == 20) + #expect(config.offset == 0) + #expect(config.fields == nil) + #expect(config.continuationMarker == nil) + #expect(config.output == .json) + } + + @Test("QueryConfig initializes with custom zone") + internal func initializeWithCustomZone() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "customZone" + ) + + #expect(config.zone == "customZone") + #expect(config.recordType == "Note") + } + + @Test("QueryConfig initializes with custom record type") + internal func initializeWithCustomRecordType() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + recordType: "Article" + ) + + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Article") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+ComplexInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+ComplexInitialization.swift new file mode 100644 index 00000000..debd32bb --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+ComplexInitialization.swift @@ -0,0 +1,82 @@ +// +// QueryConfigTests+ComplexInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Complex Initialization") + internal struct ComplexInitialization { + @Test("QueryConfig initializes with all custom values") + internal func initializeWithAllCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "customZone", + recordType: "Article", + filters: ["status=published", "category=tech"], + sort: (field: "publishedAt", order: .descending), + limit: 50, + offset: 20, + fields: ["title", "content", "author"], + continuationMarker: "marker-xyz789", + output: .yaml + ) + + #expect(config.zone == "customZone") + #expect(config.recordType == "Article") + #expect(config.filters.count == 2) + #expect(config.sort?.field == "publishedAt") + #expect(config.sort?.order == .descending) + #expect(config.limit == 50) + #expect(config.offset == 20) + #expect(config.fields?.count == 3) + #expect(config.continuationMarker == "marker-xyz789") + #expect(config.output == .yaml) + } + + @Test("QueryConfig handles pagination scenario") + internal func handlePaginationScenario() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + limit: 10, + offset: 30, + continuationMarker: "page-4" + ) + + #expect(config.limit == 10) + #expect(config.offset == 30) + #expect(config.continuationMarker == "page-4") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+ContinuationMarker.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+ContinuationMarker.swift new file mode 100644 index 00000000..1c1f5908 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+ContinuationMarker.swift @@ -0,0 +1,61 @@ +// +// QueryConfigTests+ContinuationMarker.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Continuation Marker") + internal struct ContinuationMarker { + @Test("QueryConfig initializes with nil continuation marker") + internal func initializeWithNilContinuationMarker() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + continuationMarker: nil + ) + + #expect(config.continuationMarker == nil) + } + + @Test("QueryConfig initializes with continuation marker") + internal func initializeWithContinuationMarker() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + continuationMarker: "marker-abc123" + ) + + #expect(config.continuationMarker == "marker-abc123") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+EdgeCases.swift new file mode 100644 index 00000000..3f4e6a3a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+EdgeCases.swift @@ -0,0 +1,76 @@ +// +// QueryConfigTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("QueryConfig handles special characters in filters") + internal func handleSpecialCharactersInFilters() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + filters: ["name='O'Brien'", "email~='@example.com'"] + ) + + #expect(config.filters.count == 2) + #expect(config.filters[0] == "name='O'Brien'") + } + + @Test("QueryConfig handles zero limit") + internal func handleZeroLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + limit: 0 + ) + + #expect(config.limit == 0) + } + + @Test("QueryConfig handles fields with special characters") + internal func handleFieldsWithSpecialCharacters() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + fields: ["field_name", "field-with-dash", "field.with.dot"] + ) + + #expect(config.fields?.count == 3) + #expect(config.fields?[0] == "field_name") + #expect(config.fields?[1] == "field-with-dash") + #expect(config.fields?[2] == "field.with.dot") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+FieldsFilter.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+FieldsFilter.swift new file mode 100644 index 00000000..3826e085 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+FieldsFilter.swift @@ -0,0 +1,87 @@ +// +// QueryConfigTests+FieldsFilter.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Fields Filter") + internal struct FieldsFilter { + @Test("QueryConfig initializes with nil fields") + internal func initializeWithNilFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + fields: nil + ) + + #expect(config.fields == nil) + } + + @Test("QueryConfig initializes with empty fields array") + internal func initializeWithEmptyFieldsArray() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + fields: [] + ) + + #expect(config.fields != nil) + #expect(config.fields?.isEmpty == true) + } + + @Test("QueryConfig initializes with single field") + internal func initializeWithSingleField() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + fields: ["title"] + ) + + #expect(config.fields?.count == 1) + #expect(config.fields?[0] == "title") + } + + @Test("QueryConfig initializes with multiple fields") + internal func initializeWithMultipleFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + fields: ["title", "content", "createdAt", "status"] + ) + + #expect(config.fields?.count == 4) + #expect(config.fields?[0] == "title") + #expect(config.fields?[3] == "status") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Filter.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Filter.swift new file mode 100644 index 00000000..76102eb7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Filter.swift @@ -0,0 +1,76 @@ +// +// QueryConfigTests+Filter.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Filter") + internal struct Filter { + @Test("QueryConfig initializes with empty filters") + internal func initializeWithEmptyFilters() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + filters: [] + ) + + #expect(config.filters.isEmpty) + } + + @Test("QueryConfig initializes with single filter") + internal func initializeWithSingleFilter() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + filters: ["status=active"] + ) + + #expect(config.filters.count == 1) + #expect(config.filters[0] == "status=active") + } + + @Test("QueryConfig initializes with multiple filters") + internal func initializeWithMultipleFilters() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + filters: ["status=active", "priority>5", "category=urgent"] + ) + + #expect(config.filters.count == 3) + #expect(config.filters[0] == "status=active") + #expect(config.filters[1] == "priority>5") + #expect(config.filters[2] == "category=urgent") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Limit.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Limit.swift new file mode 100644 index 00000000..7049f320 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Limit.swift @@ -0,0 +1,80 @@ +// +// QueryConfigTests+Limit.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Limit") + internal struct Limit { + @Test("QueryConfig initializes with default limit") + internal func initializeWithDefaultLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.limit == 20) + } + + @Test("QueryConfig initializes with custom limit") + internal func initializeWithCustomLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + limit: 50 + ) + + #expect(config.limit == 50) + } + + @Test("QueryConfig handles minimum limit") + internal func handleMinimumLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + limit: 1 + ) + + #expect(config.limit == 1) + } + + @Test("QueryConfig handles maximum limit") + internal func handleMaximumLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + limit: 200 + ) + + #expect(config.limit == 200) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Offset.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Offset.swift new file mode 100644 index 00000000..71911f81 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+Offset.swift @@ -0,0 +1,69 @@ +// +// QueryConfigTests+Offset.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Offset") + internal struct Offset { + @Test("QueryConfig initializes with default offset") + internal func initializeWithDefaultOffset() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.offset == 0) + } + + @Test("QueryConfig initializes with custom offset") + internal func initializeWithCustomOffset() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + offset: 10 + ) + + #expect(config.offset == 10) + } + + @Test("QueryConfig handles large offset") + internal func handleLargeOffset() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + offset: 1_000 + ) + + #expect(config.offset == 1_000) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+OutputFormat.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+OutputFormat.swift new file mode 100644 index 00000000..d4ce5e93 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+OutputFormat.swift @@ -0,0 +1,83 @@ +// +// QueryConfigTests+OutputFormat.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Output Format") + internal struct OutputFormatTests { + @Test("QueryConfig initializes with JSON output format") + internal func initializeWithJSONOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + output: .json + ) + + #expect(config.output == .json) + } + + @Test("QueryConfig initializes with CSV output format") + internal func initializeWithCSVOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + output: .csv + ) + + #expect(config.output == .csv) + } + + @Test("QueryConfig initializes with table output format") + internal func initializeWithTableOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + output: .table + ) + + #expect(config.output == .table) + } + + @Test("QueryConfig initializes with YAML output format") + internal func initializeWithYAMLOutput() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + output: .yaml + ) + + #expect(config.output == .yaml) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+SortOption.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+SortOption.swift new file mode 100644 index 00000000..d4490ab8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+SortOption.swift @@ -0,0 +1,88 @@ +// +// QueryConfigTests+SortOption.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + @Suite("Sort Option") + internal struct SortOption { + @Test("QueryConfig initializes with nil sort") + internal func initializeWithNilSort() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + sort: nil + ) + + #expect(config.sort == nil) + } + + @Test("QueryConfig initializes with ascending sort") + internal func initializeWithAscendingSort() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + sort: (field: "createdAt", order: .ascending) + ) + + #expect(config.sort?.field == "createdAt") + #expect(config.sort?.order == .ascending) + } + + @Test("QueryConfig initializes with descending sort") + internal func initializeWithDescendingSort() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + sort: (field: "updatedAt", order: .descending) + ) + + #expect(config.sort?.field == "updatedAt") + #expect(config.sort?.order == .descending) + } + + @Test("QueryConfig handles sort on different field names") + internal func handleSortOnDifferentFields() async throws { + let baseConfig = try await MistDemoConfig() + + let config1 = QueryConfig(base: baseConfig, sort: (field: "title", order: .ascending)) + #expect(config1.sort?.field == "title") + + let config2 = QueryConfig(base: baseConfig, sort: (field: "priority", order: .descending)) + #expect(config2.sort?.field == "priority") + + let config3 = QueryConfig(base: baseConfig, sort: (field: "status_code", order: .ascending)) + #expect(config3.sort?.field == "status_code") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests.swift new file mode 100644 index 00000000..a1d9bfac --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests.swift @@ -0,0 +1,33 @@ +// +// QueryConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("QueryConfig") +internal enum QueryConfigTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfigTests.swift deleted file mode 100644 index ffe4b77c..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfigTests.swift +++ /dev/null @@ -1,448 +0,0 @@ -// -// QueryConfigTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit - -@testable import MistDemo - -@Suite("QueryConfig Tests") -struct QueryConfigTests { - // MARK: - Basic Initialization Tests - - @Test("QueryConfig initializes with default values") - func initializeWithDefaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.zone == "_defaultZone") - #expect(config.recordType == "Note") - #expect(config.filters.isEmpty) - #expect(config.sort == nil) - #expect(config.limit == 20) - #expect(config.offset == 0) - #expect(config.fields == nil) - #expect(config.continuationMarker == nil) - #expect(config.output == .json) - } - - @Test("QueryConfig initializes with custom zone") - func initializeWithCustomZone() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - zone: "customZone" - ) - - #expect(config.zone == "customZone") - #expect(config.recordType == "Note") - } - - @Test("QueryConfig initializes with custom record type") - func initializeWithCustomRecordType() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - recordType: "Article" - ) - - #expect(config.zone == "_defaultZone") - #expect(config.recordType == "Article") - } - - // MARK: - Filter Tests - - @Test("QueryConfig initializes with empty filters") - func initializeWithEmptyFilters() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - filters: [] - ) - - #expect(config.filters.isEmpty) - } - - @Test("QueryConfig initializes with single filter") - func initializeWithSingleFilter() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - filters: ["status=active"] - ) - - #expect(config.filters.count == 1) - #expect(config.filters[0] == "status=active") - } - - @Test("QueryConfig initializes with multiple filters") - func initializeWithMultipleFilters() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - filters: ["status=active", "priority>5", "category=urgent"] - ) - - #expect(config.filters.count == 3) - #expect(config.filters[0] == "status=active") - #expect(config.filters[1] == "priority>5") - #expect(config.filters[2] == "category=urgent") - } - - // MARK: - Sort Option Tests - - @Test("QueryConfig initializes with nil sort") - func initializeWithNilSort() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - sort: nil - ) - - #expect(config.sort == nil) - } - - @Test("QueryConfig initializes with ascending sort") - func initializeWithAscendingSort() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - sort: (field: "createdAt", order: .ascending) - ) - - #expect(config.sort?.field == "createdAt") - #expect(config.sort?.order == .ascending) - } - - @Test("QueryConfig initializes with descending sort") - func initializeWithDescendingSort() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - sort: (field: "updatedAt", order: .descending) - ) - - #expect(config.sort?.field == "updatedAt") - #expect(config.sort?.order == .descending) - } - - @Test("QueryConfig handles sort on different field names") - func handleSortOnDifferentFields() async throws { - let baseConfig = try await MistDemoConfig() - - let config1 = QueryConfig(base: baseConfig, sort: (field: "title", order: .ascending)) - #expect(config1.sort?.field == "title") - - let config2 = QueryConfig(base: baseConfig, sort: (field: "priority", order: .descending)) - #expect(config2.sort?.field == "priority") - - let config3 = QueryConfig(base: baseConfig, sort: (field: "status_code", order: .ascending)) - #expect(config3.sort?.field == "status_code") - } - - // MARK: - Limit Tests - - @Test("QueryConfig initializes with default limit") - func initializeWithDefaultLimit() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.limit == 20) - } - - @Test("QueryConfig initializes with custom limit") - func initializeWithCustomLimit() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - limit: 50 - ) - - #expect(config.limit == 50) - } - - @Test("QueryConfig handles minimum limit") - func handleMinimumLimit() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - limit: 1 - ) - - #expect(config.limit == 1) - } - - @Test("QueryConfig handles maximum limit") - func handleMaximumLimit() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - limit: 200 - ) - - #expect(config.limit == 200) - } - - // MARK: - Offset Tests - - @Test("QueryConfig initializes with default offset") - func initializeWithDefaultOffset() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig(base: baseConfig) - - #expect(config.offset == 0) - } - - @Test("QueryConfig initializes with custom offset") - func initializeWithCustomOffset() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - offset: 10 - ) - - #expect(config.offset == 10) - } - - @Test("QueryConfig handles large offset") - func handleLargeOffset() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - offset: 1000 - ) - - #expect(config.offset == 1000) - } - - // MARK: - Fields Filter Tests - - @Test("QueryConfig initializes with nil fields") - func initializeWithNilFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - fields: nil - ) - - #expect(config.fields == nil) - } - - @Test("QueryConfig initializes with empty fields array") - func initializeWithEmptyFieldsArray() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - fields: [] - ) - - #expect(config.fields != nil) - #expect(config.fields?.isEmpty == true) - } - - @Test("QueryConfig initializes with single field") - func initializeWithSingleField() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - fields: ["title"] - ) - - #expect(config.fields?.count == 1) - #expect(config.fields?[0] == "title") - } - - @Test("QueryConfig initializes with multiple fields") - func initializeWithMultipleFields() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - fields: ["title", "content", "createdAt", "status"] - ) - - #expect(config.fields?.count == 4) - #expect(config.fields?[0] == "title") - #expect(config.fields?[3] == "status") - } - - // MARK: - Continuation Marker Tests - - @Test("QueryConfig initializes with nil continuation marker") - func initializeWithNilContinuationMarker() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - continuationMarker: nil - ) - - #expect(config.continuationMarker == nil) - } - - @Test("QueryConfig initializes with continuation marker") - func initializeWithContinuationMarker() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - continuationMarker: "marker-abc123" - ) - - #expect(config.continuationMarker == "marker-abc123") - } - - // MARK: - Output Format Tests - - @Test("QueryConfig initializes with JSON output format") - func initializeWithJSONOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - output: .json - ) - - #expect(config.output == .json) - } - - @Test("QueryConfig initializes with CSV output format") - func initializeWithCSVOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - output: .csv - ) - - #expect(config.output == .csv) - } - - @Test("QueryConfig initializes with table output format") - func initializeWithTableOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - output: .table - ) - - #expect(config.output == .table) - } - - @Test("QueryConfig initializes with YAML output format") - func initializeWithYAMLOutput() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - output: .yaml - ) - - #expect(config.output == .yaml) - } - - // MARK: - Complex Initialization Tests - - @Test("QueryConfig initializes with all custom values") - func initializeWithAllCustomValues() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - zone: "customZone", - recordType: "Article", - filters: ["status=published", "category=tech"], - sort: (field: "publishedAt", order: .descending), - limit: 50, - offset: 20, - fields: ["title", "content", "author"], - continuationMarker: "marker-xyz789", - output: .yaml - ) - - #expect(config.zone == "customZone") - #expect(config.recordType == "Article") - #expect(config.filters.count == 2) - #expect(config.sort?.field == "publishedAt") - #expect(config.sort?.order == .descending) - #expect(config.limit == 50) - #expect(config.offset == 20) - #expect(config.fields?.count == 3) - #expect(config.continuationMarker == "marker-xyz789") - #expect(config.output == .yaml) - } - - @Test("QueryConfig handles pagination scenario") - func handlePaginationScenario() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - limit: 10, - offset: 30, - continuationMarker: "page-4" - ) - - #expect(config.limit == 10) - #expect(config.offset == 30) - #expect(config.continuationMarker == "page-4") - } - - // MARK: - Edge Cases - - @Test("QueryConfig handles special characters in filters") - func handleSpecialCharactersInFilters() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - filters: ["name='O'Brien'", "email~='@example.com'"] - ) - - #expect(config.filters.count == 2) - #expect(config.filters[0] == "name='O'Brien'") - } - - @Test("QueryConfig handles zero limit") - func handleZeroLimit() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - limit: 0 - ) - - #expect(config.limit == 0) - } - - @Test("QueryConfig handles fields with special characters") - func handleFieldsWithSpecialCharacters() async throws { - let baseConfig = try await MistDemoConfig() - let config = QueryConfig( - base: baseConfig, - fields: ["field_name", "field-with-dash", "field.with.dot"] - ) - - #expect(config.fields?.count == 3) - #expect(config.fields?[0] == "field_name") - #expect(config.fields?[1] == "field-with-dash") - #expect(config.fields?[2] == "field.with.dot") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift new file mode 100644 index 00000000..16155ba3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -0,0 +1,89 @@ +// +// TestPrivateConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +@Suite("TestPrivateConfig Tests") +internal struct TestPrivateConfigTests { + @Test("Memberwise defaults: recordCount=10, assetSizeKB=100, flags false, lookupEmail nil") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPrivateConfig(base: baseConfig) + + #expect(config.recordCount == 10) + #expect(config.assetSizeKB == 100) + #expect(config.skipCleanup == false) + #expect(config.verbose == false) + #expect(config.lookupEmail == nil) + } + + @Test("Memberwise init accepts every custom value") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPrivateConfig( + base: baseConfig, + recordCount: 42, + assetSizeKB: 2_048, + skipCleanup: true, + verbose: true, + lookupEmail: "user@example.com" + ) + + #expect(config.recordCount == 42) + #expect(config.assetSizeKB == 2_048) + #expect(config.skipCleanup == true) + #expect(config.verbose == true) + #expect(config.lookupEmail == "user@example.com") + } + + @Test("Configuration init pins database to private regardless of input") + internal func pinsDatabaseToPrivate() async throws { + // Even though we configure the base for the public DB, TestPrivateConfig + // must override to `.private`. The init also requires web-auth credentials. + let baseConfig = try await MistDemoConfig( + database: .public(.prefers(.serverToServer)), + webAuthToken: "wat-xyz" + ) + let config = TestPrivateConfig(base: baseConfig.with(database: .private)) + + #expect(config.base.database == MistKit.Database.private) + } + + @Test("Memberwise init preserves base configuration values") + internal func preservesBase() async throws { + let baseConfig = try await MistDemoConfig(containerIdentifier: "iCloud.private.test") + let config = TestPrivateConfig(base: baseConfig) + + #expect(config.base.containerIdentifier == "iCloud.private.test") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPublicConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPublicConfigTests.swift new file mode 100644 index 00000000..69451f7c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPublicConfigTests.swift @@ -0,0 +1,83 @@ +// +// TestPublicConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("TestPublicConfig Tests") +internal struct TestPublicConfigTests { + @Test("Memberwise defaults: recordCount=10, assetSizeKB=100, flags false, lookupEmail nil") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPublicConfig(base: baseConfig) + + #expect(config.recordCount == 10) + #expect(config.assetSizeKB == 100) + #expect(config.skipCleanup == false) + #expect(config.verbose == false) + #expect(config.lookupEmail == nil) + } + + @Test("Memberwise init accepts custom values") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPublicConfig( + base: baseConfig, + recordCount: 25, + assetSizeKB: 512, + skipCleanup: true, + verbose: true, + lookupEmail: "user@example.com" + ) + + #expect(config.recordCount == 25) + #expect(config.assetSizeKB == 512) + #expect(config.skipCleanup == true) + #expect(config.verbose == true) + #expect(config.lookupEmail == "user@example.com") + } + + @Test("Memberwise init preserves base configuration values") + internal func preservesBase() async throws { + let baseConfig = try await MistDemoConfig(containerIdentifier: "iCloud.integration.test") + let config = TestPublicConfig(base: baseConfig) + + #expect(config.base.containerIdentifier == "iCloud.integration.test") + } + + @Test("Memberwise init accepts zero recordCount") + internal func zeroRecordCount() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPublicConfig(base: baseConfig, recordCount: 0) + + #expect(config.recordCount == 0) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+BasicInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+BasicInitialization.swift new file mode 100644 index 00000000..2f02d39f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+BasicInitialization.swift @@ -0,0 +1,89 @@ +// +// UpdateConfigTests+BasicInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension UpdateConfigTests { + @Suite("Basic Initialization") + internal struct BasicInitialization { + @Test("UpdateConfig initializes with defaults") + internal func initializeWithDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1") + + #expect(config.recordName == "rec1") + #expect(config.zone == "_defaultZone") + #expect(config.recordType == "Note") + #expect(config.recordChangeTag == nil) + #expect(config.force == false) + #expect(config.fields.isEmpty) + #expect(config.output == .json) + } + + @Test("UpdateConfig initializes with custom zone") + internal func initializeWithCustomZone() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, zone: "customZone", recordName: "rec1") + + #expect(config.zone == "customZone") + } + + @Test("UpdateConfig initializes with custom record type") + internal func initializeWithCustomRecordType() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordType: "Article", recordName: "rec1") + + #expect(config.recordType == "Article") + } + + @Test("UpdateConfig initializes with record change tag") + internal func initializeWithRecordChangeTag() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig( + base: baseConfig, + recordName: "rec1", + recordChangeTag: "tag-abc123" + ) + + #expect(config.recordChangeTag == "tag-abc123") + } + + @Test("UpdateConfig initializes without record change tag") + internal func initializeWithoutRecordChangeTag() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", recordChangeTag: nil) + + #expect(config.recordChangeTag == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+CombinedEdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+CombinedEdgeCases.swift new file mode 100644 index 00000000..09254f22 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+CombinedEdgeCases.swift @@ -0,0 +1,74 @@ +// +// UpdateConfigTests+CombinedEdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension UpdateConfigTests { + @Suite("Combined / Edge Cases") + internal struct CombinedEdgeCases { + @Test("UpdateConfig initializes with all custom values") + internal func initializeWithAllCustomValues() async throws { + let baseConfig = try await MistDemoConfig() + let fields = [ + Field(name: "title", type: .string, value: "x"), + Field(name: "n", type: .int64, value: "1"), + ] + let config = UpdateConfig( + base: baseConfig, + zone: "Z", + recordType: "T", + recordName: "R", + recordChangeTag: "tag", + force: true, + fields: fields, + output: .yaml + ) + + #expect(config.zone == "Z") + #expect(config.recordType == "T") + #expect(config.recordName == "R") + #expect(config.recordChangeTag == "tag") + #expect(config.force == true) + #expect(config.fields.count == 2) + #expect(config.output == .yaml) + } + + @Test("UpdateConfig handles special characters in record name") + internal func specialCharactersInRecordName() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec-name_with.special@chars") + + #expect(config.recordName == "rec-name_with.special@chars") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+FieldInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+FieldInitialization.swift new file mode 100644 index 00000000..2c737440 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+FieldInitialization.swift @@ -0,0 +1,75 @@ +// +// UpdateConfigTests+FieldInitialization.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension UpdateConfigTests { + @Suite("Field Initialization") + internal struct FieldInitialization { + @Test("UpdateConfig initializes with empty fields") + internal func initializeWithEmptyFields() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", fields: []) + + #expect(config.fields.isEmpty) + } + + @Test("UpdateConfig initializes with single field") + internal func initializeWithSingleField() async throws { + let baseConfig = try await MistDemoConfig() + let field = Field(name: "title", type: .string, value: "Updated Title") + let config = UpdateConfig(base: baseConfig, recordName: "rec1", fields: [field]) + + #expect(config.fields.count == 1) + #expect(config.fields[0].name == "title") + #expect(config.fields[0].type == .string) + #expect(config.fields[0].value == "Updated Title") + } + + @Test("UpdateConfig initializes with multiple fields of various types") + internal func initializeWithMultipleFields() async throws { + let baseConfig = try await MistDemoConfig() + let fields = [ + Field(name: "title", type: .string, value: "New Title"), + Field(name: "count", type: .int64, value: "42"), + Field(name: "ratio", type: .double, value: "3.14"), + ] + let config = UpdateConfig(base: baseConfig, recordName: "rec1", fields: fields) + + #expect(config.fields.count == 3) + #expect(config.fields[0].type == .string) + #expect(config.fields[1].type == .int64) + #expect(config.fields[2].type == .double) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+ForceFlag.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+ForceFlag.swift new file mode 100644 index 00000000..7fdfaad7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+ForceFlag.swift @@ -0,0 +1,70 @@ +// +// UpdateConfigTests+ForceFlag.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension UpdateConfigTests { + @Suite("Force Flag") + internal struct ForceFlag { + @Test("UpdateConfig defaults force to false") + internal func forceDefaultsFalse() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1") + + #expect(config.force == false) + } + + @Test("UpdateConfig accepts force=true") + internal func forceCanBeTrue() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", force: true) + + #expect(config.force == true) + } + + @Test("UpdateConfig preserves recordChangeTag when force is set (caller decides effect)") + internal func forceWithChangeTagBothPreserved() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig( + base: baseConfig, + recordName: "rec1", + recordChangeTag: "tag-1", + force: true + ) + + // The Config holds both values; UpdateCommand decides to ignore the tag when force=true. + #expect(config.recordChangeTag == "tag-1") + #expect(config.force == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+OutputFormat.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+OutputFormat.swift new file mode 100644 index 00000000..f88afed6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests+OutputFormat.swift @@ -0,0 +1,71 @@ +// +// UpdateConfigTests+OutputFormat.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension UpdateConfigTests { + @Suite("Output Format") + internal struct OutputFormatTests { + @Test("UpdateConfig accepts JSON output format") + internal func outputJSON() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", output: .json) + + #expect(config.output == .json) + } + + @Test("UpdateConfig accepts table output format") + internal func outputTable() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", output: .table) + + #expect(config.output == .table) + } + + @Test("UpdateConfig accepts CSV output format") + internal func outputCSV() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", output: .csv) + + #expect(config.output == .csv) + } + + @Test("UpdateConfig accepts YAML output format") + internal func outputYAML() async throws { + let baseConfig = try await MistDemoConfig() + let config = UpdateConfig(base: baseConfig, recordName: "rec1", output: .yaml) + + #expect(config.output == .yaml) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests.swift new file mode 100644 index 00000000..755c2ccd --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateConfigTests.swift @@ -0,0 +1,35 @@ +// +// UpdateConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("UpdateConfig") +internal enum UpdateConfigTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateErrorTests.swift new file mode 100644 index 00000000..5be62b49 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UpdateConfig/UpdateErrorTests.swift @@ -0,0 +1,71 @@ +// +// UpdateErrorTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("UpdateError") +internal struct UpdateErrorTests { + @Test("conflict with nil reason produces a generic conflict description") + internal func conflictNilReason() { + let error = UpdateError.conflict(reason: nil) + let description = error.errorDescription ?? "" + + #expect(description.contains("conflict")) + } + + @Test("conflict with reason includes the reason in the description") + internal func conflictWithReason() { + let error = UpdateError.conflict(reason: "ATOMIC_ERROR") + let description = error.errorDescription ?? "" + + #expect(description.contains("ATOMIC_ERROR")) + } + + @Test("conflict suggests --force as a remedy") + internal func conflictRecoveryMentionsForce() { + let error = UpdateError.conflict(reason: nil) + let suggestion = error.recoverySuggestion ?? "" + + #expect(suggestion.contains("--force")) + } + + @Test("recordNameRequired has a description") + internal func recordNameRequiredDescription() { + let error = UpdateError.recordNameRequired + #expect(error.errorDescription != nil) + } + + @Test("noFieldsProvided has a description") + internal func noFieldsProvidedDescription() { + let error = UpdateError.noFieldsProvided + #expect(error.errorDescription != nil) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift new file mode 100644 index 00000000..cecb0092 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift @@ -0,0 +1,102 @@ +// +// UploadAssetConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("UploadAssetConfig Tests") +internal struct UploadAssetConfigTests { + @Test("Memberwise init applies recordName=nil and json output by default") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/tmp/photo.jpg", + recordType: "Note", + fieldName: "image" + ) + + #expect(config.file == "/tmp/photo.jpg") + #expect(config.recordType == "Note") + #expect(config.fieldName == "image") + #expect(config.recordName == nil) + #expect(config.output == .json) + } + + @Test("Memberwise init accepts all custom values") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/var/data/photo.png", + recordType: "Photo", + fieldName: "thumbnail", + recordName: "rec-123", + output: .yaml + ) + + #expect(config.file == "/var/data/photo.png") + #expect(config.recordType == "Photo") + #expect(config.fieldName == "thumbnail") + #expect(config.recordName == "rec-123") + #expect(config.output == .yaml) + } + + @Test( + "UploadAssetConfig output formats round-trip", + arguments: [OutputFormat.json, .table, .csv, .yaml] + ) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/tmp/photo.jpg", + recordType: "Note", + fieldName: "image", + output: format + ) + + #expect(config.output == format) + } + + @Test("UploadAssetConfig preserves a file path containing spaces") + internal func pathWithSpaces() async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/var/data/My Photos/img.jpg", + recordType: "Note", + fieldName: "image" + ) + + #expect(config.file == "/var/data/My Photos/img.jpg") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorDescription.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorDescription.swift new file mode 100644 index 00000000..4a9eb718 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorDescription.swift @@ -0,0 +1,111 @@ +// +// CreateErrorTests+ErrorDescription.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateErrorTests { + @Suite("Error Description") + internal struct ErrorDescription { + @Test("noFieldsProvided error description") + internal func noFieldsProvidedDescription() { + let error = CreateError.noFieldsProvided + let description = error.errorDescription + + #expect(description != nil) + #expect(description == MistDemoConstants.Messages.noFieldsProvided) + } + + @Test("invalidJSONFormat error description") + internal func invalidJSONFormatDescription() { + let error = CreateError.invalidJSONFormat("unexpected token") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Invalid JSON format") == true) + #expect(description?.contains("unexpected token") == true) + } + + @Test("jsonFileError error description") + internal func jsonFileErrorDescription() { + let error = CreateError.jsonFileError("test.json", "file not found") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Error reading JSON file") == true) + #expect(description?.contains("test.json") == true) + #expect(description?.contains("file not found") == true) + } + + @Test("emptyStdin error description") + internal func emptyStdinDescription() { + let error = CreateError.emptyStdin + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Empty stdin") == true) + #expect(description?.contains("JSON object") == true) + } + + @Test("stdinError error description") + internal func stdinErrorDescription() { + let error = CreateError.stdinError("read failed") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Error reading from stdin") == true) + #expect(description?.contains("read failed") == true) + } + + @Test("fieldConversionError error description") + internal func fieldConversionErrorDescription() { + let error = CreateError.fieldConversionError("age", .int64, "invalid", "not a number") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Failed to convert field") == true) + #expect(description?.contains("age") == true) + #expect(description?.contains("int64") == true) + #expect(description?.contains("invalid") == true) + #expect(description?.contains("not a number") == true) + } + + @Test("operationFailed error description") + internal func operationFailedDescription() { + let error = CreateError.operationFailed("network timeout") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Create operation failed") == true) + #expect(description?.contains("network timeout") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorMessageContent.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorMessageContent.swift new file mode 100644 index 00000000..dd350026 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorMessageContent.swift @@ -0,0 +1,54 @@ +// +// CreateErrorTests+ErrorMessageContent.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateErrorTests { + @Suite("Error Message Content") + internal struct ErrorMessageContent { + @Test("fieldConversionError includes all components") + internal func fieldConversionErrorComponents() throws { + let fieldName = "temperature" + let fieldType = FieldType.double + let value = "not_a_number" + let reason = "Invalid format" + + let error = CreateError.fieldConversionError(fieldName, fieldType, value, reason) + let description = try #require(error.errorDescription) + + #expect(description.contains(fieldName)) + #expect(description.contains(fieldType.rawValue)) + #expect(description.contains(value)) + #expect(description.contains(reason)) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorThrowing.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorThrowing.swift new file mode 100644 index 00000000..7df9747c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+ErrorThrowing.swift @@ -0,0 +1,56 @@ +// +// CreateErrorTests+ErrorThrowing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateErrorTests { + @Suite("Error Throwing") + internal struct ErrorThrowing { + @Test("Can throw and catch CreateError") + internal func throwAndCatch() { + #expect(throws: CreateError.self) { + throw CreateError.noFieldsProvided + } + } + + @Test("Can pattern match on specific error case") + internal func patternMatch() { + let error = CreateError.invalidJSONFormat("test") + + if case .invalidJSONFormat(let message) = error { + #expect(message == "test") + } else { + Issue.record("Pattern match failed") + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+LocalizedErrorConformance.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+LocalizedErrorConformance.swift new file mode 100644 index 00000000..a0536195 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests+LocalizedErrorConformance.swift @@ -0,0 +1,62 @@ +// +// CreateErrorTests+LocalizedErrorConformance.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateErrorTests { + @Suite("LocalizedError Conformance") + internal struct LocalizedErrorConformance { + @Test("CreateError conforms to LocalizedError") + internal func conformsToLocalizedError() { + let error: any Error = CreateError.noFieldsProvided + #expect(error is LocalizedError) + } + + @Test("All error cases have non-nil descriptions") + internal func allCasesHaveDescriptions() { + let errors: [CreateError] = [ + .noFieldsProvided, + .invalidJSONFormat("test"), + .jsonFileError("file.json", "error"), + .emptyStdin, + .stdinError("error"), + .fieldConversionError("field", .string, "value", "error"), + .operationFailed("reason"), + ] + + for error in errors { + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests.swift new file mode 100644 index 00000000..5c6b94e9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateError/CreateErrorTests.swift @@ -0,0 +1,33 @@ +// +// CreateErrorTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CreateError") +internal enum CreateErrorTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateErrorTests.swift deleted file mode 100644 index bf64734c..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Errors/CreateErrorTests.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// CreateErrorTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("CreateError Tests") -struct CreateErrorTests { - - // MARK: - Error Description Tests - - @Test("noFieldsProvided error description") - func noFieldsProvidedDescription() { - let error = CreateError.noFieldsProvided - let description = error.errorDescription - - #expect(description != nil) - #expect(description == MistDemoConstants.Messages.noFieldsProvided) - } - - @Test("invalidJSONFormat error description") - func invalidJSONFormatDescription() { - let error = CreateError.invalidJSONFormat("unexpected token") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Invalid JSON format") == true) - #expect(description?.contains("unexpected token") == true) - } - - @Test("jsonFileError error description") - func jsonFileErrorDescription() { - let error = CreateError.jsonFileError("test.json", "file not found") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Error reading JSON file") == true) - #expect(description?.contains("test.json") == true) - #expect(description?.contains("file not found") == true) - } - - @Test("emptyStdin error description") - func emptyStdinDescription() { - let error = CreateError.emptyStdin - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Empty stdin") == true) - #expect(description?.contains("JSON object") == true) - } - - @Test("stdinError error description") - func stdinErrorDescription() { - let error = CreateError.stdinError("read failed") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Error reading from stdin") == true) - #expect(description?.contains("read failed") == true) - } - - @Test("fieldConversionError error description") - func fieldConversionErrorDescription() { - let error = CreateError.fieldConversionError("age", .int64, "invalid", "not a number") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Failed to convert field") == true) - #expect(description?.contains("age") == true) - #expect(description?.contains("int64") == true) - #expect(description?.contains("invalid") == true) - #expect(description?.contains("not a number") == true) - } - - @Test("operationFailed error description") - func operationFailedDescription() { - let error = CreateError.operationFailed("network timeout") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Create operation failed") == true) - #expect(description?.contains("network timeout") == true) - } - - // MARK: - LocalizedError Conformance Tests - - @Test("CreateError conforms to LocalizedError") - func conformsToLocalizedError() { - let error: any Error = CreateError.noFieldsProvided - #expect(error is LocalizedError) - } - - @Test("All error cases have non-nil descriptions") - func allCasesHaveDescriptions() { - let errors: [CreateError] = [ - .noFieldsProvided, - .invalidJSONFormat("test"), - .jsonFileError("file.json", "error"), - .emptyStdin, - .stdinError("error"), - .fieldConversionError("field", .string, "value", "error"), - .operationFailed("reason") - ] - - for error in errors { - #expect(error.errorDescription != nil) - #expect(!error.errorDescription!.isEmpty) - } - } - - // MARK: - Error Throwing Tests - - @Test("Can throw and catch CreateError") - func throwAndCatch() { - #expect(throws: CreateError.self) { - throw CreateError.noFieldsProvided - } - } - - @Test("Can pattern match on specific error case") - func patternMatch() { - let error = CreateError.invalidJSONFormat("test") - - if case .invalidJSONFormat(let message) = error { - #expect(message == "test") - } else { - Issue.record("Pattern match failed") - } - } - - // MARK: - Error Message Content Tests - - @Test("fieldConversionError includes all components") - func fieldConversionErrorComponents() { - let fieldName = "temperature" - let fieldType = FieldType.double - let value = "not_a_number" - let reason = "Invalid format" - - let error = CreateError.fieldConversionError(fieldName, fieldType, value, reason) - let description = error.errorDescription! - - #expect(description.contains(fieldName)) - #expect(description.contains(fieldType.rawValue)) - #expect(description.contains(value)) - #expect(description.contains(reason)) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/CurrentUserErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/CurrentUserErrorTests.swift index abeb25eb..8ca42175 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Errors/CurrentUserErrorTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/CurrentUserErrorTests.swift @@ -29,112 +29,112 @@ import Foundation import Testing -@testable import MistDemo -@Suite("CurrentUserError Tests") -struct CurrentUserErrorTests { - - // MARK: - Error Description Tests +@testable import MistDemoKit - @Test("operationFailed error description") - func operationFailedDescription() { - let error = CurrentUserError.operationFailed("network timeout") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Current user operation failed") == true) - #expect(description?.contains("network timeout") == true) +@Suite("CurrentUserError Tests") +internal struct CurrentUserErrorTests { + // MARK: - Error Description Tests + + @Test("operationFailed error description") + internal func operationFailedDescription() { + let error = CurrentUserError.operationFailed("network timeout") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Current user operation failed") == true) + #expect(description?.contains("network timeout") == true) + } + + @Test("authenticationRequired error description") + internal func authenticationRequiredDescription() { + let error = CurrentUserError.authenticationRequired + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Authentication is required") == true) + #expect(description?.contains("current-user") == true) + #expect(description?.contains("auth-token") == true) + } + + // MARK: - LocalizedError Conformance Tests + + @Test("CurrentUserError conforms to LocalizedError") + internal func conformsToLocalizedError() { + let error: any Error = CurrentUserError.authenticationRequired + #expect(error is LocalizedError) + } + + @Test("All error cases have non-nil descriptions") + internal func allCasesHaveDescriptions() { + let errors: [CurrentUserError] = [ + .operationFailed("test reason"), + .authenticationRequired, + ] + + for error in errors { + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) } + } - @Test("authenticationRequired error description") - func authenticationRequiredDescription() { - let error = CurrentUserError.authenticationRequired - let description = error.errorDescription + // MARK: - Error Throwing Tests - #expect(description != nil) - #expect(description?.contains("Authentication is required") == true) - #expect(description?.contains("current-user") == true) - #expect(description?.contains("auth-token") == true) + @Test("Can throw and catch CurrentUserError") + internal func throwAndCatch() { + #expect(throws: CurrentUserError.self) { + throw CurrentUserError.authenticationRequired } + } - // MARK: - LocalizedError Conformance Tests + @Test("Can pattern match on specific error case") + internal func patternMatch() { + let error = CurrentUserError.operationFailed("test") - @Test("CurrentUserError conforms to LocalizedError") - func conformsToLocalizedError() { - let error: any Error = CurrentUserError.authenticationRequired - #expect(error is LocalizedError) + if case .operationFailed(let message) = error { + #expect(message == "test") + } else { + Issue.record("Pattern match failed") } + } - @Test("All error cases have non-nil descriptions") - func allCasesHaveDescriptions() { - let errors: [CurrentUserError] = [ - .operationFailed("test reason"), - .authenticationRequired - ] - - for error in errors { - #expect(error.errorDescription != nil) - #expect(!error.errorDescription!.isEmpty) - } - } + // MARK: - Error Message Content Tests - // MARK: - Error Throwing Tests + @Test("authenticationRequired provides recovery suggestion") + internal func authenticationRequiredSuggestion() throws { + let error = CurrentUserError.authenticationRequired + let description = try #require(error.errorDescription) - @Test("Can throw and catch CurrentUserError") - func throwAndCatch() { - #expect(throws: CurrentUserError.self) { - throw CurrentUserError.authenticationRequired - } - } + #expect(description.contains("auth-token")) + #expect(description.contains("--web-auth-token")) + } - @Test("Can pattern match on specific error case") - func patternMatch() { - let error = CurrentUserError.operationFailed("test") + @Test("operationFailed includes error message") + internal func operationFailedIncludesMessage() throws { + let message = "Server returned 500" + let error = CurrentUserError.operationFailed(message) + let description = try #require(error.errorDescription) - if case .operationFailed(let message) = error { - #expect(message == "test") - } else { - Issue.record("Pattern match failed") - } - } + #expect(description.contains(message)) + } - // MARK: - Error Message Content Tests + // MARK: - Error Type Tests - @Test("authenticationRequired provides recovery suggestion") - func authenticationRequiredSuggestion() { - let error = CurrentUserError.authenticationRequired - let description = error.errorDescription! + @Test("Different error cases are distinguishable") + internal func errorCasesDistinguishable() { + let error1 = CurrentUserError.authenticationRequired + let error2 = CurrentUserError.operationFailed("test") - #expect(description.contains("auth-token")) - #expect(description.contains("--web-auth-token")) + if case .authenticationRequired = error1 { + // Success + } else { + Issue.record("Error case mismatch") } - @Test("operationFailed includes error message") - func operationFailedIncludesMessage() { - let message = "Server returned 500" - let error = CurrentUserError.operationFailed(message) - let description = error.errorDescription! - - #expect(description.contains(message)) - } - - // MARK: - Error Type Tests - - @Test("Different error cases are distinguishable") - func errorCasesDistinguishable() { - let error1 = CurrentUserError.authenticationRequired - let error2 = CurrentUserError.operationFailed("test") - - if case .authenticationRequired = error1 { - // Success - } else { - Issue.record("Error case mismatch") - } - - if case .operationFailed = error2 { - // Success - } else { - Issue.record("Error case mismatch") - } + if case .operationFailed = error2 { + // Success + } else { + Issue.record("Error case mismatch") } + } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/ErrorOutputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/ErrorOutputTests.swift index 69e2e609..dcd2c5bd 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Errors/ErrorOutputTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/ErrorOutputTests.swift @@ -30,14 +30,14 @@ import Foundation import Testing -@testable import MistDemo +@testable import MistDemoKit @Suite("ErrorOutput Tests") -struct ErrorOutputTests { +internal struct ErrorOutputTests { // MARK: - Basic Structure Tests @Test("Create error output with all fields") - func createErrorOutputWithAllFields() { + internal func createErrorOutputWithAllFields() { let errorOutput = ErrorOutput( code: "TEST_ERROR", message: "This is a test error", @@ -53,7 +53,7 @@ struct ErrorOutputTests { } @Test("Create error output without optional fields") - func createErrorOutputWithoutOptionalFields() { + internal func createErrorOutputWithoutOptionalFields() { let errorOutput = ErrorOutput( code: "SIMPLE_ERROR", message: "Simple error message" @@ -68,7 +68,7 @@ struct ErrorOutputTests { // MARK: - JSON Serialization Tests @Test("Serialize error output to JSON") - func serializeToJSON() throws { + internal func serializeToJSON() throws { let errorOutput = ErrorOutput( code: "AUTH_FAILED", message: "Authentication failed", @@ -88,7 +88,7 @@ struct ErrorOutputTests { } @Test("Serialize error output to pretty JSON") - func serializeToPrettyJSON() throws { + internal func serializeToPrettyJSON() throws { let errorOutput = ErrorOutput( code: "CONFIG_ERROR", message: "Configuration error" @@ -103,7 +103,7 @@ struct ErrorOutputTests { } @Test("JSON output has correct structure") - func jsonOutputHasCorrectStructure() throws { + internal func jsonOutputHasCorrectStructure() throws { let errorOutput = ErrorOutput( code: "TEST", message: "Test message", @@ -122,7 +122,7 @@ struct ErrorOutputTests { // MARK: - Edge Cases @Test("Handle empty details dictionary") - func handleEmptyDetails() throws { + internal func handleEmptyDetails() throws { let errorOutput = ErrorOutput( code: "ERROR", message: "Message", @@ -134,7 +134,7 @@ struct ErrorOutputTests { } @Test("Handle special characters in message") - func handleSpecialCharacters() throws { + internal func handleSpecialCharacters() throws { let errorOutput = ErrorOutput( code: "SPECIAL", message: "Error with \"quotes\" and \\ backslash" @@ -148,7 +148,7 @@ struct ErrorOutputTests { } @Test("Handle multiline suggestion") - func handleMultilineSuggestion() throws { + internal func handleMultilineSuggestion() throws { let errorOutput = ErrorOutput( code: "HELP", message: "Need help", diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorCode.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorCode.swift new file mode 100644 index 00000000..9f5bb3dc --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorCode.swift @@ -0,0 +1,74 @@ +// +// MistDemoErrorTests+ErrorCode.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistDemoErrorTests { + @Suite("Error Code") + internal struct ErrorCode { + @Test("Authentication failed error has correct code") + internal func authenticationFailedErrorCode() { + let error = MistDemoError.authenticationFailed( + description: "Test error description", + context: "test context" + ) + + #expect(error.errorCode == "AUTHENTICATION_FAILED") + } + + @Test("Configuration error has correct code") + internal func configurationErrorCode() { + let error = MistDemoError.configurationError("test", suggestion: nil) + #expect(error.errorCode == "CONFIGURATION_ERROR") + } + + @Test("CloudKit error has correct code") + internal func cloudKitErrorCode() { + let error = MistDemoError.cloudKitError( + .networkError(URLError(.badURL)), + operation: "fetch" + ) + #expect(error.errorCode == "CLOUDKIT_ERROR") + } + + @Test("Invalid input error has correct code") + internal func invalidInputErrorCode() { + let error = MistDemoError.invalidInput( + field: "email", + value: "invalid", + reason: "not a valid email" + ) + #expect(error.errorCode == "INVALID_INPUT") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorDescription.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorDescription.swift new file mode 100644 index 00000000..e22519e2 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorDescription.swift @@ -0,0 +1,77 @@ +// +// MistDemoErrorTests+ErrorDescription.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistDemoErrorTests { + @Suite("Error Description") + internal struct ErrorDescription { + @Test("Authentication failed error has descriptive message") + internal func authenticationFailedDescription() { + let error = MistDemoError.authenticationFailed( + description: "Invalid credentials", + context: "credential validation" + ) + + let description = error.errorDescription + #expect(description?.contains("Authentication failed") == true) + #expect(description?.contains("credential validation") == true) + } + + @Test("Configuration error has descriptive message") + internal func configurationErrorDescription() { + let error = MistDemoError.configurationError( + "Missing API token", + suggestion: "Set CLOUDKIT_API_TOKEN" + ) + + let description = error.errorDescription + #expect(description?.contains("Configuration error") == true) + #expect(description?.contains("Missing API token") == true) + } + + @Test("Invalid input error includes field and reason") + internal func invalidInputDescription() { + let error = MistDemoError.invalidInput( + field: "port", + value: "abc", + reason: "must be a number" + ) + + let description = error.errorDescription + #expect(description?.contains("port") == true) + #expect(description?.contains("abc") == true) + #expect(description?.contains("must be a number") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorDetails.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorDetails.swift new file mode 100644 index 00000000..ad638450 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorDetails.swift @@ -0,0 +1,75 @@ +// +// MistDemoErrorTests+ErrorDetails.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension MistDemoErrorTests { + @Suite("Error Details") + internal struct ErrorDetails { + @Test("Authentication failed includes context in details") + internal func authenticationFailedDetails() { + let error = MistDemoError.authenticationFailed( + description: "Invalid token", + context: "web auth validation" + ) + + let details = error.errorDetails + #expect(details["context"] == "web auth validation") + } + + @Test("CloudKit error includes operation in details") + internal func cloudKitErrorDetails() { + let error = MistDemoError.cloudKitError( + .networkError(URLError(.badURL)), + operation: "list_zones" + ) + + let details = error.errorDetails + #expect(details["operation"] == "list_zones") + } + + @Test("Invalid input includes all fields in details") + internal func invalidInputDetails() { + let error = MistDemoError.invalidInput( + field: "api-token", + value: "short", + reason: "too short" + ) + + let details = error.errorDetails + #expect(details["field"] == "api-token") + #expect(details["value"] == "short") + #expect(details["reason"] == "too short") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorOutputConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorOutputConversion.swift new file mode 100644 index 00000000..ec5a6680 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+ErrorOutputConversion.swift @@ -0,0 +1,69 @@ +// +// MistDemoErrorTests+ErrorOutputConversion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension MistDemoErrorTests { + @Suite("Error Output Conversion") + internal struct ErrorOutputConversion { + @Test("Convert error to ErrorOutput") + internal func convertToErrorOutput() { + let error = MistDemoError.fileNotFound("/path/to/file") + let output = error.errorOutput + + #expect(output.error.code == "FILE_NOT_FOUND") + #expect(output.error.message.contains("not found") == true) + #expect(output.error.details?["path"] == "/path/to/file") + } + + @Test("ErrorOutput includes suggestion when available") + internal func errorOutputIncludesSuggestion() { + let error = MistDemoError.configurationError( + "Missing token", + suggestion: "Use --api-token flag" + ) + let output = error.errorOutput + + #expect(output.error.suggestion == "Use --api-token flag") + } + + @Test("ErrorOutput omits empty details") + internal func errorOutputOmitsEmptyDetails() { + let error = MistDemoError.outputFormattingFailed( + description: "Encoding failed" + ) + let output = error.errorOutput + + #expect(output.error.details == nil || output.error.details?.isEmpty == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+RecoverySuggestion.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+RecoverySuggestion.swift new file mode 100644 index 00000000..b0334b42 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests+RecoverySuggestion.swift @@ -0,0 +1,72 @@ +// +// MistDemoErrorTests+RecoverySuggestion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension MistDemoErrorTests { + @Suite("Recovery Suggestion") + internal struct RecoverySuggestion { + @Test("Authentication failed has recovery suggestion") + internal func authenticationFailedRecoverySuggestion() { + let error = MistDemoError.authenticationFailed( + description: "Test error description", + context: "test" + ) + + let suggestion = error.recoverySuggestion + #expect(suggestion?.contains("mistdemo auth") == true) + } + + @Test("Configuration error uses provided suggestion") + internal func configurationErrorRecoverySuggestion() { + let error = MistDemoError.configurationError( + "Test error", + suggestion: "Custom suggestion" + ) + + let suggestion = error.recoverySuggestion + #expect(suggestion == "Custom suggestion") + } + + @Test("Invalid input has recovery suggestion") + internal func invalidInputRecoverySuggestion() { + let error = MistDemoError.invalidInput( + field: "container-id", + value: "bad", + reason: "invalid format" + ) + + let suggestion = error.recoverySuggestion + #expect(suggestion?.contains("container-id") == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests.swift new file mode 100644 index 00000000..0578039c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoError/MistDemoErrorTests.swift @@ -0,0 +1,33 @@ +// +// MistDemoErrorTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("MistDemoError") +internal enum MistDemoErrorTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoErrorTests.swift deleted file mode 100644 index ae4cae00..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Errors/MistDemoErrorTests.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// MistDemoErrorTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit -import Testing - -@testable import MistDemo - -@Suite("MistDemoError Tests") -struct MistDemoErrorTests { - // MARK: - Error Code Tests - - @Test("Authentication failed error has correct code") - func authenticationFailedErrorCode() { - let error = MistDemoError.authenticationFailed( - description: "Test error description", - context: "test context" - ) - - #expect(error.errorCode == "AUTHENTICATION_FAILED") - } - - @Test("Configuration error has correct code") - func configurationErrorCode() { - let error = MistDemoError.configurationError("test", suggestion: nil) - #expect(error.errorCode == "CONFIGURATION_ERROR") - } - - @Test("CloudKit error has correct code") - func cloudKitErrorCode() { - let error = MistDemoError.cloudKitError( - .networkError(URLError(.badURL)), - operation: "fetch" - ) - #expect(error.errorCode == "CLOUDKIT_ERROR") - } - - @Test("Invalid input error has correct code") - func invalidInputErrorCode() { - let error = MistDemoError.invalidInput( - field: "email", - value: "invalid", - reason: "not a valid email" - ) - #expect(error.errorCode == "INVALID_INPUT") - } - - // MARK: - Error Description Tests - - @Test("Authentication failed error has descriptive message") - func authenticationFailedDescription() { - let error = MistDemoError.authenticationFailed( - description: "Invalid credentials", - context: "credential validation" - ) - - let description = error.errorDescription - #expect(description?.contains("Authentication failed") == true) - #expect(description?.contains("credential validation") == true) - } - - @Test("Configuration error has descriptive message") - func configurationErrorDescription() { - let error = MistDemoError.configurationError( - "Missing API token", - suggestion: "Set CLOUDKIT_API_TOKEN" - ) - - let description = error.errorDescription - #expect(description?.contains("Configuration error") == true) - #expect(description?.contains("Missing API token") == true) - } - - @Test("Invalid input error includes field and reason") - func invalidInputDescription() { - let error = MistDemoError.invalidInput( - field: "port", - value: "abc", - reason: "must be a number" - ) - - let description = error.errorDescription - #expect(description?.contains("port") == true) - #expect(description?.contains("abc") == true) - #expect(description?.contains("must be a number") == true) - } - - // MARK: - Recovery Suggestion Tests - - @Test("Authentication failed has recovery suggestion") - func authenticationFailedRecoverySuggestion() { - let error = MistDemoError.authenticationFailed( - description: "Test error description", - context: "test" - ) - - let suggestion = error.recoverySuggestion - #expect(suggestion?.contains("mistdemo auth") == true) - } - - @Test("Configuration error uses provided suggestion") - func configurationErrorRecoverySuggestion() { - let error = MistDemoError.configurationError( - "Test error", - suggestion: "Custom suggestion" - ) - - let suggestion = error.recoverySuggestion - #expect(suggestion == "Custom suggestion") - } - - @Test("Invalid input has recovery suggestion") - func invalidInputRecoverySuggestion() { - let error = MistDemoError.invalidInput( - field: "container-id", - value: "bad", - reason: "invalid format" - ) - - let suggestion = error.recoverySuggestion - #expect(suggestion?.contains("container-id") == true) - } - - // MARK: - Error Details Tests - - @Test("Authentication failed includes context in details") - func authenticationFailedDetails() { - let error = MistDemoError.authenticationFailed( - description: "Invalid token", - context: "web auth validation" - ) - - let details = error.errorDetails - #expect(details["context"] == "web auth validation") - } - - @Test("CloudKit error includes operation in details") - func cloudKitErrorDetails() { - let error = MistDemoError.cloudKitError( - .networkError(URLError(.badURL)), - operation: "list_zones" - ) - - let details = error.errorDetails - #expect(details["operation"] == "list_zones") - } - - @Test("Invalid input includes all fields in details") - func invalidInputDetails() { - let error = MistDemoError.invalidInput( - field: "api-token", - value: "short", - reason: "too short" - ) - - let details = error.errorDetails - #expect(details["field"] == "api-token") - #expect(details["value"] == "short") - #expect(details["reason"] == "too short") - } - - // MARK: - ErrorOutput Conversion Tests - - @Test("Convert error to ErrorOutput") - func convertToErrorOutput() { - let error = MistDemoError.fileNotFound("/path/to/file") - let output = error.errorOutput - - #expect(output.error.code == "FILE_NOT_FOUND") - #expect(output.error.message.contains("not found") == true) - #expect(output.error.details?["path"] == "/path/to/file") - } - - @Test("ErrorOutput includes suggestion when available") - func errorOutputIncludesSuggestion() { - let error = MistDemoError.configurationError( - "Missing token", - suggestion: "Use --api-token flag" - ) - let output = error.errorOutput - - #expect(output.error.suggestion == "Use --api-token flag") - } - - @Test("ErrorOutput omits empty details") - func errorOutputOmitsEmptyDetails() { - let error = MistDemoError.outputFormattingFailed( - description: "Encoding failed" - ) - let output = error.errorOutput - - #expect(output.error.details == nil || output.error.details?.isEmpty == true) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Errors/QueryErrorTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Errors/QueryErrorTests.swift index 2c5a19e3..fc0d34a9 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Errors/QueryErrorTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Errors/QueryErrorTests.swift @@ -29,154 +29,156 @@ import Foundation import Testing -@testable import MistDemo -@Suite("QueryError Tests") -struct QueryErrorTests { - - // MARK: - Error Description Tests - - @Test("invalidLimit error description") - func invalidLimitDescription() { - let error = QueryError.invalidLimit(500) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("500") == true) - #expect(description?.contains(String(MistDemoConstants.Limits.minQueryLimit)) == true) - #expect(description?.contains(String(MistDemoConstants.Limits.maxQueryLimit)) == true) - } - - @Test("invalidFilter error description") - func invalidFilterDescription() { - let error = QueryError.invalidFilter("invalid:filter", expected: "field:op:value") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Invalid filter") == true) - #expect(description?.contains("invalid:filter") == true) - #expect(description?.contains("field:op:value") == true) - } - - @Test("emptyFieldName error description") - func emptyFieldNameDescription() { - let error = QueryError.emptyFieldName(":eq:value") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Empty field name") == true) - #expect(description?.contains(":eq:value") == true) - } - - @Test("invalidSortOrder error description") - func invalidSortOrderDescription() { - let error = QueryError.invalidSortOrder("invalid", available: ["asc", "desc"]) - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Invalid sort order") == true) - #expect(description?.contains("invalid") == true) - #expect(description?.contains("asc") == true) - #expect(description?.contains("desc") == true) - } - - @Test("unsupportedOperator error description") - func unsupportedOperatorDescription() { - let error = QueryError.unsupportedOperator("regex") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Unsupported filter operator") == true) - #expect(description?.contains("regex") == true) - #expect(description?.contains("eq") == true) - #expect(description?.contains("ne") == true) - #expect(description?.contains("gt") == true) - } - - @Test("operationFailed error description") - func operationFailedDescription() { - let error = QueryError.operationFailed("network error") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Query operation failed") == true) - #expect(description?.contains("network error") == true) - } - - // MARK: - LocalizedError Conformance Tests +@testable import MistDemoKit - @Test("QueryError conforms to LocalizedError") - func conformsToLocalizedError() { - let error: any Error = QueryError.invalidLimit(0) - #expect(error is LocalizedError) - } - - @Test("All error cases have non-nil descriptions") - func allCasesHaveDescriptions() { - let errors: [QueryError] = [ - .invalidLimit(500), - .invalidFilter("filter", expected: "expected"), - .emptyFieldName("filter"), - .invalidSortOrder("order", available: ["asc", "desc"]), - .unsupportedOperator("op"), - .operationFailed("reason") - ] - - for error in errors { - #expect(error.errorDescription != nil) - #expect(!error.errorDescription!.isEmpty) - } +@Suite("QueryError Tests") +internal struct QueryErrorTests { + // MARK: - Error Description Tests + + @Test("invalidLimit error description") + internal func invalidLimitDescription() { + let error = QueryError.invalidLimit(500) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("500") == true) + #expect(description?.contains(String(MistDemoConstants.Limits.minQueryLimit)) == true) + #expect(description?.contains(String(MistDemoConstants.Limits.maxQueryLimit)) == true) + } + + @Test("invalidFilter error description") + internal func invalidFilterDescription() { + let error = QueryError.invalidFilter("invalid:filter", expected: "field:op:value") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Invalid filter") == true) + #expect(description?.contains("invalid:filter") == true) + #expect(description?.contains("field:op:value") == true) + } + + @Test("emptyFieldName error description") + internal func emptyFieldNameDescription() { + let error = QueryError.emptyFieldName(":eq:value") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Empty field name") == true) + #expect(description?.contains(":eq:value") == true) + } + + @Test("invalidSortOrder error description") + internal func invalidSortOrderDescription() { + let error = QueryError.invalidSortOrder("invalid", available: ["asc", "desc"]) + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Invalid sort order") == true) + #expect(description?.contains("invalid") == true) + #expect(description?.contains("asc") == true) + #expect(description?.contains("desc") == true) + } + + @Test("unsupportedOperator error description") + internal func unsupportedOperatorDescription() { + let error = QueryError.unsupportedOperator("regex") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Unsupported filter operator") == true) + #expect(description?.contains("regex") == true) + #expect(description?.contains("eq") == true) + #expect(description?.contains("ne") == true) + #expect(description?.contains("gt") == true) + } + + @Test("operationFailed error description") + internal func operationFailedDescription() { + let error = QueryError.operationFailed("network error") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Query operation failed") == true) + #expect(description?.contains("network error") == true) + } + + // MARK: - LocalizedError Conformance Tests + + @Test("QueryError conforms to LocalizedError") + internal func conformsToLocalizedError() { + let error: any Error = QueryError.invalidLimit(0) + #expect(error is LocalizedError) + } + + @Test("All error cases have non-nil descriptions") + internal func allCasesHaveDescriptions() { + let errors: [QueryError] = [ + .invalidLimit(500), + .invalidFilter("filter", expected: "expected"), + .emptyFieldName("filter"), + .invalidSortOrder("order", available: ["asc", "desc"]), + .unsupportedOperator("op"), + .operationFailed("reason"), + ] + + for error in errors { + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) } + } - // MARK: - Error Throwing Tests + // MARK: - Error Throwing Tests - @Test("Can throw and catch QueryError") - func throwAndCatch() { - #expect(throws: QueryError.self) { - throw QueryError.invalidLimit(0) - } + @Test("Can throw and catch QueryError") + internal func throwAndCatch() { + #expect(throws: QueryError.self) { + throw QueryError.invalidLimit(0) } + } - @Test("Can pattern match on specific error case") - func patternMatch() { - let error = QueryError.invalidLimit(500) + @Test("Can pattern match on specific error case") + internal func patternMatch() { + let error = QueryError.invalidLimit(500) - if case .invalidLimit(let limit) = error { - #expect(limit == 500) - } else { - Issue.record("Pattern match failed") - } + if case .invalidLimit(let limit) = error { + #expect(limit == 500) + } else { + Issue.record("Pattern match failed") } + } - // MARK: - Specific Error Case Tests + // MARK: - Specific Error Case Tests - @Test("invalidLimit with negative value") - func invalidLimitNegative() { - let error = QueryError.invalidLimit(-1) - let description = error.errorDescription! + @Test("invalidLimit with negative value") + internal func invalidLimitNegative() throws { + let error = QueryError.invalidLimit(-1) + let description = try #require(error.errorDescription) - #expect(description.contains("-1")) - } + #expect(description.contains("-1")) + } - @Test("invalidSortOrder shows all available options") - func invalidSortOrderShowsOptions() { - let availableOrders = ["asc", "desc", "ascending", "descending"] - let error = QueryError.invalidSortOrder("bad", available: availableOrders) - let description = error.errorDescription! + @Test("invalidSortOrder shows all available options") + internal func invalidSortOrderShowsOptions() throws { + let availableOrders = ["asc", "desc", "ascending", "descending"] + let error = QueryError.invalidSortOrder("bad", available: availableOrders) + let description = try #require(error.errorDescription) - for order in availableOrders { - #expect(description.contains(order)) - } + for order in availableOrders { + #expect(description.contains(order)) } - - @Test("unsupportedOperator lists supported operators") - func unsupportedOperatorListsSupported() { - let error = QueryError.unsupportedOperator("unknown") - let description = error.errorDescription! - - let supportedOps = ["eq", "ne", "gt", "gte", "lt", "lte", "contains", "begins_with", "in", "not_in"] - for op in supportedOps { - #expect(description.contains(op)) - } + } + + @Test("unsupportedOperator lists supported operators") + internal func unsupportedOperatorListsSupported() throws { + let error = QueryError.unsupportedOperator("unknown") + let description = try #require(error.errorDescription) + + let supportedOps = [ + "eq", "ne", "gt", "gte", "lt", "lte", "contains", "begins_with", "in", "not_in", + ] + for supportedOp in supportedOps { + #expect(description.contains(supportedOp)) } + } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+BooleanConfigKeyWithPrefix.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+BooleanConfigKeyWithPrefix.swift new file mode 100644 index 00000000..883e3df6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+BooleanConfigKeyWithPrefix.swift @@ -0,0 +1,63 @@ +// +// ConfigKey+MistDemoTests+BooleanConfigKeyWithPrefix.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation +import Testing + +@testable import MistDemoKit + +extension ConfigKeyMistDemoTests { + @Suite("Boolean ConfigKey with MISTDEMO Prefix") + internal struct BooleanConfigKeyWithPrefix { + @Test("Boolean ConfigKey with mistDemoPrefixed and default true") + internal func booleanConfigKeyDefaultTrue() { + let key = ConfigKey(mistDemoPrefixed: "debug.enabled", default: true) + + #expect(key.base == "debug.enabled") + #expect(key.defaultValue == true) + } + + @Test("Boolean ConfigKey with mistDemoPrefixed and default false") + internal func booleanConfigKeyDefaultFalse() { + let key = ConfigKey(mistDemoPrefixed: "feature.flag", default: false) + + #expect(key.base == "feature.flag") + #expect(key.defaultValue == false) + } + + @Test("Boolean ConfigKey with implicit default false") + internal func booleanConfigKeyImplicitDefault() { + let key = ConfigKey(mistDemoPrefixed: "test.flag") + + #expect(key.base == "test.flag") + #expect(key.defaultValue == false) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+ConfigKeyWithPrefix.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+ConfigKeyWithPrefix.swift new file mode 100644 index 00000000..5e7abd83 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+ConfigKeyWithPrefix.swift @@ -0,0 +1,64 @@ +// +// ConfigKey+MistDemoTests+ConfigKeyWithPrefix.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation +import Testing + +@testable import MistDemoKit + +extension ConfigKeyMistDemoTests { + @Suite("ConfigKey with MISTDEMO Prefix") + internal struct ConfigKeyWithPrefix { + @Test("ConfigKey with mistDemoPrefixed initializer") + internal func configKeyWithMistDemoPrefix() { + let key = ConfigKey(mistDemoPrefixed: "test.key", default: "default-value") + + #expect(key.base == "test.key") + #expect(key.defaultValue == "default-value") + } + + @Test("ConfigKey mistDemoPrefixed with string default") + internal func mistDemoPrefixedStringDefault() { + let key = ConfigKey(mistDemoPrefixed: "api.token", default: "default-token") + + #expect(key.base == "api.token") + #expect(key.defaultValue == "default-token") + } + + @Test("ConfigKey mistDemoPrefixed with different base keys") + internal func mistDemoPrefixedDifferentKeys() { + let key1 = ConfigKey(mistDemoPrefixed: "key.one", default: "value1") + let key2 = ConfigKey(mistDemoPrefixed: "key.two", default: "value2") + + #expect(key1.base != key2.base) + #expect(key1.defaultValue != key2.defaultValue) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+EdgeCases.swift new file mode 100644 index 00000000..ad2a0523 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+EdgeCases.swift @@ -0,0 +1,56 @@ +// +// ConfigKey+MistDemoTests+EdgeCases.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation +import Testing + +@testable import MistDemoKit + +extension ConfigKeyMistDemoTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("ConfigKey with empty base string") + internal func configKeyWithEmptyBase() { + let key = ConfigKey(mistDemoPrefixed: "", default: "value") + + #expect(key.base?.isEmpty == true) + } + + @Test("ConfigKey with dotted path") + internal func configKeyWithDottedPath() { + let key = ConfigKey( + mistDemoPrefixed: "cloudkit.api.token", + default: "default" + ) + + #expect(key.base == "cloudkit.api.token") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+OptionalConfigKeyWithPrefix.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+OptionalConfigKeyWithPrefix.swift new file mode 100644 index 00000000..1aa9cbf2 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+OptionalConfigKeyWithPrefix.swift @@ -0,0 +1,55 @@ +// +// ConfigKey+MistDemoTests+OptionalConfigKeyWithPrefix.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation +import Testing + +@testable import MistDemoKit + +extension ConfigKeyMistDemoTests { + @Suite("OptionalConfigKey with MISTDEMO Prefix") + internal struct OptionalConfigKeyWithPrefix { + @Test("OptionalConfigKey with mistDemoPrefixed initializer") + internal func optionalConfigKeyWithMistDemoPrefix() { + let key = OptionalConfigKey(mistDemoPrefixed: "optional.key") + + #expect(key.base == "optional.key") + } + + @Test("OptionalConfigKey mistDemoPrefixed for different types") + internal func optionalConfigKeyDifferentTypes() { + let stringKey = OptionalConfigKey(mistDemoPrefixed: "string.key") + let intKey = OptionalConfigKey(mistDemoPrefixed: "int.key") + + #expect(stringKey.base == "string.key") + #expect(intKey.base == "int.key") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+RealWorldUsage.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+RealWorldUsage.swift new file mode 100644 index 00000000..d3804841 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests+RealWorldUsage.swift @@ -0,0 +1,65 @@ +// +// ConfigKey+MistDemoTests+RealWorldUsage.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation +import Testing + +@testable import MistDemoKit + +extension ConfigKeyMistDemoTests { + @Suite("Real-world Usage") + internal struct RealWorldUsage { + @Test("Create config key for container identifier") + internal func containerIdentifierKey() { + let key = ConfigKey( + mistDemoPrefixed: "container.identifier", + default: "iCloud.com.brightdigit.MistDemo" + ) + + #expect(key.base == "container.identifier") + #expect(key.defaultValue == "iCloud.com.brightdigit.MistDemo") + } + + @Test("Create optional config key for web auth token") + internal func webAuthTokenKey() { + let key = OptionalConfigKey(mistDemoPrefixed: "web.auth.token") + + #expect(key.base == "web.auth.token") + } + + @Test("Create boolean config key for skip auth flag") + internal func skipAuthFlagKey() { + let key = ConfigKey(mistDemoPrefixed: "skip.auth", default: false) + + #expect(key.base == "skip.auth") + #expect(key.defaultValue == false) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests.swift new file mode 100644 index 00000000..73e08030 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemo/ConfigKey+MistDemoTests.swift @@ -0,0 +1,33 @@ +// +// ConfigKey+MistDemoTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("ConfigKey+MistDemo") +internal enum ConfigKeyMistDemoTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemoTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemoTests.swift deleted file mode 100644 index 90a7f32a..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/ConfigKey+MistDemoTests.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// ConfigKey+MistDemoTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo -import ConfigKeyKit - -@Suite("ConfigKey+MistDemo Tests") -struct ConfigKeyMistDemoTests { - - // MARK: - ConfigKey with MISTDEMO Prefix Tests - - @Test("ConfigKey with mistDemoPrefixed initializer") - func configKeyWithMistDemoPrefix() { - let key = ConfigKey(mistDemoPrefixed: "test.key", default: "default-value") - - #expect(key.base == "test.key") - #expect(key.defaultValue == "default-value") - } - - @Test("ConfigKey mistDemoPrefixed with string default") - func mistDemoPrefixedStringDefault() { - let key = ConfigKey(mistDemoPrefixed: "api.token", default: "default-token") - - #expect(key.base == "api.token") - #expect(key.defaultValue == "default-token") - } - - @Test("ConfigKey mistDemoPrefixed with different base keys") - func mistDemoPrefixedDifferentKeys() { - let key1 = ConfigKey(mistDemoPrefixed: "key.one", default: "value1") - let key2 = ConfigKey(mistDemoPrefixed: "key.two", default: "value2") - - #expect(key1.base != key2.base) - #expect(key1.defaultValue != key2.defaultValue) - } - - // MARK: - OptionalConfigKey with MISTDEMO Prefix Tests - - @Test("OptionalConfigKey with mistDemoPrefixed initializer") - func optionalConfigKeyWithMistDemoPrefix() { - let key = OptionalConfigKey(mistDemoPrefixed: "optional.key") - - #expect(key.base == "optional.key") - } - - @Test("OptionalConfigKey mistDemoPrefixed for different types") - func optionalConfigKeyDifferentTypes() { - let stringKey = OptionalConfigKey(mistDemoPrefixed: "string.key") - let intKey = OptionalConfigKey(mistDemoPrefixed: "int.key") - - #expect(stringKey.base == "string.key") - #expect(intKey.base == "int.key") - } - - // MARK: - Boolean ConfigKey with MISTDEMO Prefix Tests - - @Test("Boolean ConfigKey with mistDemoPrefixed and default true") - func booleanConfigKeyDefaultTrue() { - let key = ConfigKey(mistDemoPrefixed: "debug.enabled", default: true) - - #expect(key.base == "debug.enabled") - #expect(key.defaultValue == true) - } - - @Test("Boolean ConfigKey with mistDemoPrefixed and default false") - func booleanConfigKeyDefaultFalse() { - let key = ConfigKey(mistDemoPrefixed: "feature.flag", default: false) - - #expect(key.base == "feature.flag") - #expect(key.defaultValue == false) - } - - @Test("Boolean ConfigKey with implicit default false") - func booleanConfigKeyImplicitDefault() { - let key = ConfigKey(mistDemoPrefixed: "test.flag") - - #expect(key.base == "test.flag") - #expect(key.defaultValue == false) - } - - // MARK: - Real-world Usage Tests - - @Test("Create config key for container identifier") - func containerIdentifierKey() { - let key = ConfigKey( - mistDemoPrefixed: "container.identifier", - default: "iCloud.com.brightdigit.MistDemo" - ) - - #expect(key.base == "container.identifier") - #expect(key.defaultValue == "iCloud.com.brightdigit.MistDemo") - } - - @Test("Create optional config key for web auth token") - func webAuthTokenKey() { - let key = OptionalConfigKey(mistDemoPrefixed: "web.auth.token") - - #expect(key.base == "web.auth.token") - } - - @Test("Create boolean config key for skip auth flag") - func skipAuthFlagKey() { - let key = ConfigKey(mistDemoPrefixed: "skip.auth", default: false) - - #expect(key.base == "skip.auth") - #expect(key.defaultValue == false) - } - - // MARK: - Edge Cases - - @Test("ConfigKey with empty base string") - func configKeyWithEmptyBase() { - let key = ConfigKey(mistDemoPrefixed: "", default: "value") - - #expect(key.base == "") - } - - @Test("ConfigKey with dotted path") - func configKeyWithDottedPath() { - let key = ConfigKey( - mistDemoPrefixed: "cloudkit.api.token", - default: "default" - ) - - #expect(key.base == "cloudkit.api.token") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift new file mode 100644 index 00000000..3dc22cce --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift @@ -0,0 +1,58 @@ +// +// FieldValue+FieldTypeTests+BytesType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("Bytes Type") + internal struct BytesType { + @Test("Initialize FieldValue.bytes from String value and bytes type") + internal func initializeBytesFromStringValue() { + let fieldValue = FieldValue(value: "base64data" as String, fieldType: .bytes) + + #expect(fieldValue != nil) + if case .bytes(let value) = fieldValue { + #expect(value == "base64data") + } else { + Issue.record("Expected .bytes case") + } + } + + @Test("Bytes type with non-String value returns nil") + internal func bytesTypeWithNonStringValueReturnsNil() { + let fieldValue = FieldValue(value: 42 as Int, fieldType: .bytes) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift new file mode 100644 index 00000000..64dce7a3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift @@ -0,0 +1,101 @@ +// +// FieldValue+FieldTypeTests+DoubleType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("Double Type") + internal struct DoubleType { + @Test("Initialize FieldValue.double from Double value") + internal func initializeDoubleFromDoubleValue() { + let fieldValue = FieldValue(value: 19.99 as Double, fieldType: .double) + + #expect(fieldValue != nil) + if case .double(let value) = fieldValue { + #expect(value == 19.99) + } else { + Issue.record("Expected .double case") + } + } + + @Test("Initialize FieldValue.double from negative Double") + internal func initializeDoubleFromNegativeDouble() { + let fieldValue = FieldValue(value: -3.14 as Double, fieldType: .double) + + #expect(fieldValue != nil) + if case .double(let value) = fieldValue { + #expect(value == -3.14) + } else { + Issue.record("Expected .double case") + } + } + + @Test("Initialize FieldValue.double from zero") + internal func initializeDoubleFromZero() { + let fieldValue = FieldValue(value: 0.0 as Double, fieldType: .double) + + #expect(fieldValue != nil) + if case .double(let value) = fieldValue { + #expect(value == 0.0) + } else { + Issue.record("Expected .double case") + } + } + + @Test("Initialize FieldValue.double from integer Double") + internal func initializeDoubleFromIntegerDouble() { + let fieldValue = FieldValue(value: 42.0 as Double, fieldType: .double) + + #expect(fieldValue != nil) + if case .double(let value) = fieldValue { + #expect(value == 42.0) + } else { + Issue.record("Expected .double case") + } + } + + @Test("Double type with non-Double value returns nil") + internal func doubleTypeWithNonDoubleValueReturnsNil() { + let fieldValue = FieldValue(value: "not a number" as String, fieldType: .double) + + #expect(fieldValue == nil) + } + + @Test("Double type with Int value returns nil") + internal func doubleTypeWithIntValueReturnsNil() { + let fieldValue = FieldValue(value: 42 as Int, fieldType: .double) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift new file mode 100644 index 00000000..1b2a7503 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift @@ -0,0 +1,112 @@ +// +// FieldValue+FieldTypeTests+Int64Type.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("Int64 Type") + internal struct Int64Type { + @Test("Initialize FieldValue.int64 from Int64 value") + internal func initializeInt64FromInt64Value() { + let fieldValue = FieldValue(value: Int64(42), fieldType: .int64) + + #expect(fieldValue != nil) + if case .int64(let value) = fieldValue { + #expect(value == 42) + } else { + Issue.record("Expected .int64 case") + } + } + + @Test("Initialize FieldValue.int64 from Int value") + internal func initializeInt64FromIntValue() { + let fieldValue = FieldValue(value: 42 as Int, fieldType: .int64) + + #expect(fieldValue != nil) + if case .int64(let value) = fieldValue { + #expect(value == 42) + } else { + Issue.record("Expected .int64 case") + } + } + + @Test("Initialize FieldValue.int64 from negative Int64") + internal func initializeInt64FromNegativeInt64() { + let fieldValue = FieldValue(value: Int64(-123), fieldType: .int64) + + #expect(fieldValue != nil) + if case .int64(let value) = fieldValue { + #expect(value == -123) + } else { + Issue.record("Expected .int64 case") + } + } + + @Test("Initialize FieldValue.int64 from zero") + internal func initializeInt64FromZero() { + let fieldValue = FieldValue(value: Int64(0), fieldType: .int64) + + #expect(fieldValue != nil) + if case .int64(let value) = fieldValue { + #expect(value == 0) + } else { + Issue.record("Expected .int64 case") + } + } + + @Test( + "Initialize FieldValue.int64 from Int64.max", + .enabled( + if: Int.bitWidth >= 64, + "FieldValue.int64 stores Int; Int64.max overflows native Int on 32-bit platforms (wasm32)" + ) + ) + internal func initializeInt64FromMaxValue() { + let fieldValue = FieldValue(value: Int64.max, fieldType: .int64) + + #expect(fieldValue != nil) + if case .int64(let value) = fieldValue { + #expect(value == Int(Int64.max)) + } else { + Issue.record("Expected .int64 case") + } + } + + @Test("Int64 type with non-numeric value returns nil") + internal func int64TypeWithNonNumericValueReturnsNil() { + let fieldValue = FieldValue(value: "not a number" as String, fieldType: .int64) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+InvalidTypeConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+InvalidTypeConversion.swift new file mode 100644 index 00000000..c9a8c4b9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+InvalidTypeConversion.swift @@ -0,0 +1,60 @@ +// +// FieldValue+FieldTypeTests+InvalidTypeConversion.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("Invalid Type Conversion") + internal struct InvalidTypeConversion { + @Test("Wrong type conversion returns nil (String as Int64)") + internal func wrongTypeConversionStringAsInt64() { + let fieldValue = FieldValue(value: "42" as String, fieldType: .int64) + + #expect(fieldValue == nil) + } + + @Test("Wrong type conversion returns nil (Int as String)") + internal func wrongTypeConversionIntAsString() { + let fieldValue = FieldValue(value: 42 as Int, fieldType: .string) + + #expect(fieldValue == nil) + } + + @Test("Wrong type conversion returns nil (Double as Int64)") + internal func wrongTypeConversionDoubleAsInt64() { + let fieldValue = FieldValue(value: 19.99 as Double, fieldType: .int64) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift new file mode 100644 index 00000000..05a364e6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift @@ -0,0 +1,70 @@ +// +// FieldValue+FieldTypeTests+StringType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("String Type") + internal struct StringType { + @Test("Initialize FieldValue.string from String value and string type") + internal func initializeStringFromStringValue() { + let fieldValue = FieldValue(value: "Hello World" as String, fieldType: .string) + + #expect(fieldValue != nil) + if case .string(let value) = fieldValue { + #expect(value == "Hello World") + } else { + Issue.record("Expected .string case") + } + } + + @Test("Initialize FieldValue.string from empty String") + internal func initializeStringFromEmptyString() { + let fieldValue = FieldValue(value: "" as String, fieldType: .string) + + #expect(fieldValue != nil) + if case .string(let value) = fieldValue { + #expect(value.isEmpty) + } else { + Issue.record("Expected .string case") + } + } + + @Test("String type with non-String value returns nil") + internal func stringTypeWithNonStringValueReturnsNil() { + let fieldValue = FieldValue(value: 42 as Int, fieldType: .string) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift new file mode 100644 index 00000000..fdef00bc --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift @@ -0,0 +1,92 @@ +// +// FieldValue+FieldTypeTests+TimestampDateType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("Timestamp/Date Type") + internal struct TimestampDateType { + @Test("Initialize FieldValue.date from Date value and timestamp type") + internal func initializeDateFromDateValue() { + let date = Date(timeIntervalSince1970: 1_705_315_800) + let fieldValue = FieldValue(value: date, fieldType: .timestamp) + + #expect(fieldValue != nil) + if case .date(let value) = fieldValue { + #expect(value.timeIntervalSince1970 == 1_705_315_800) + } else { + Issue.record("Expected .date case") + } + } + + @Test("Initialize FieldValue.date from epoch date") + internal func initializeDateFromEpochDate() { + let date = Date(timeIntervalSince1970: 0) + let fieldValue = FieldValue(value: date, fieldType: .timestamp) + + #expect(fieldValue != nil) + if case .date(let value) = fieldValue { + #expect(value.timeIntervalSince1970 == 0) + } else { + Issue.record("Expected .date case") + } + } + + @Test("Initialize FieldValue.date from current date") + internal func initializeDateFromCurrentDate() { + let date = Date() + let fieldValue = FieldValue(value: date, fieldType: .timestamp) + + #expect(fieldValue != nil) + if case .date(let value) = fieldValue { + #expect(value.timeIntervalSince1970 == date.timeIntervalSince1970) + } else { + Issue.record("Expected .date case") + } + } + + @Test("Timestamp type with non-Date value returns nil") + internal func timestampTypeWithNonDateValueReturnsNil() { + let fieldValue = FieldValue(value: "2024-01-15" as String, fieldType: .timestamp) + + #expect(fieldValue == nil) + } + + @Test("Timestamp type with Int value returns nil") + internal func timestampTypeWithIntValueReturnsNil() { + let fieldValue = FieldValue(value: 1_705_315_800 as Int, fieldType: .timestamp) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift new file mode 100644 index 00000000..1b2d2e2a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift @@ -0,0 +1,65 @@ +// +// FieldValue+FieldTypeTests+UnsupportedType.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension FieldValueFieldTypeTests { + @Suite("Unsupported Type") + internal struct UnsupportedType { + @Test("Asset type returns asset FieldValue") + internal func assetTypeReturnsNil() { + let fieldValue = FieldValue(value: "anything" as String, fieldType: .asset) + + #expect(fieldValue != nil) + if case .asset(let asset) = fieldValue { + #expect(asset.downloadURL == "anything") + } else { + Issue.record("Expected .asset case") + } + } + + @Test("Location type returns nil") + internal func locationTypeReturnsNil() { + let fieldValue = FieldValue(value: "anything" as String, fieldType: .location) + + #expect(fieldValue == nil) + } + + @Test("Reference type returns nil") + internal func referenceTypeReturnsNil() { + let fieldValue = FieldValue(value: "anything" as String, fieldType: .reference) + + #expect(fieldValue == nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests.swift new file mode 100644 index 00000000..0fee26b3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests.swift @@ -0,0 +1,33 @@ +// +// FieldValue+FieldTypeTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("FieldValue+FieldType Initialization") +internal enum FieldValueFieldTypeTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldTypeTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldTypeTests.swift deleted file mode 100644 index 90741e26..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldTypeTests.swift +++ /dev/null @@ -1,330 +0,0 @@ -// -// FieldValue+FieldTypeTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -import MistKit -@testable import MistDemo - -@Suite("FieldValue+FieldType Initialization Tests") -struct FieldValueFieldTypeTests { - - // MARK: - String Type Tests - - @Test("Initialize FieldValue.string from String value and string type") - func initializeStringFromStringValue() { - let fieldValue = FieldValue(value: "Hello World" as String, fieldType: .string) - - #expect(fieldValue != nil) - if case .string(let value) = fieldValue { - #expect(value == "Hello World") - } else { - Issue.record("Expected .string case") - } - } - - @Test("Initialize FieldValue.string from empty String") - func initializeStringFromEmptyString() { - let fieldValue = FieldValue(value: "" as String, fieldType: .string) - - #expect(fieldValue != nil) - if case .string(let value) = fieldValue { - #expect(value == "") - } else { - Issue.record("Expected .string case") - } - } - - @Test("String type with non-String value returns nil") - func stringTypeWithNonStringValueReturnsNil() { - let fieldValue = FieldValue(value: 42 as Int, fieldType: .string) - - #expect(fieldValue == nil) - } - - // MARK: - Int64 Type Tests - - @Test("Initialize FieldValue.int64 from Int64 value") - func initializeInt64FromInt64Value() { - let fieldValue = FieldValue(value: Int64(42), fieldType: .int64) - - #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { - #expect(value == 42) - } else { - Issue.record("Expected .int64 case") - } - } - - @Test("Initialize FieldValue.int64 from Int value") - func initializeInt64FromIntValue() { - let fieldValue = FieldValue(value: 42 as Int, fieldType: .int64) - - #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { - #expect(value == 42) - } else { - Issue.record("Expected .int64 case") - } - } - - @Test("Initialize FieldValue.int64 from negative Int64") - func initializeInt64FromNegativeInt64() { - let fieldValue = FieldValue(value: Int64(-123), fieldType: .int64) - - #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { - #expect(value == -123) - } else { - Issue.record("Expected .int64 case") - } - } - - @Test("Initialize FieldValue.int64 from zero") - func initializeInt64FromZero() { - let fieldValue = FieldValue(value: Int64(0), fieldType: .int64) - - #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { - #expect(value == 0) - } else { - Issue.record("Expected .int64 case") - } - } - - @Test("Initialize FieldValue.int64 from Int64.max") - func initializeInt64FromMaxValue() { - let fieldValue = FieldValue(value: Int64.max, fieldType: .int64) - - #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { - #expect(value == Int(Int64.max)) - } else { - Issue.record("Expected .int64 case") - } - } - - @Test("Int64 type with non-numeric value returns nil") - func int64TypeWithNonNumericValueReturnsNil() { - let fieldValue = FieldValue(value: "not a number" as String, fieldType: .int64) - - #expect(fieldValue == nil) - } - - // MARK: - Double Type Tests - - @Test("Initialize FieldValue.double from Double value") - func initializeDoubleFromDoubleValue() { - let fieldValue = FieldValue(value: 19.99 as Double, fieldType: .double) - - #expect(fieldValue != nil) - if case .double(let value) = fieldValue { - #expect(value == 19.99) - } else { - Issue.record("Expected .double case") - } - } - - @Test("Initialize FieldValue.double from negative Double") - func initializeDoubleFromNegativeDouble() { - let fieldValue = FieldValue(value: -3.14 as Double, fieldType: .double) - - #expect(fieldValue != nil) - if case .double(let value) = fieldValue { - #expect(value == -3.14) - } else { - Issue.record("Expected .double case") - } - } - - @Test("Initialize FieldValue.double from zero") - func initializeDoubleFromZero() { - let fieldValue = FieldValue(value: 0.0 as Double, fieldType: .double) - - #expect(fieldValue != nil) - if case .double(let value) = fieldValue { - #expect(value == 0.0) - } else { - Issue.record("Expected .double case") - } - } - - @Test("Initialize FieldValue.double from integer Double") - func initializeDoubleFromIntegerDouble() { - let fieldValue = FieldValue(value: 42.0 as Double, fieldType: .double) - - #expect(fieldValue != nil) - if case .double(let value) = fieldValue { - #expect(value == 42.0) - } else { - Issue.record("Expected .double case") - } - } - - @Test("Double type with non-Double value returns nil") - func doubleTypeWithNonDoubleValueReturnsNil() { - let fieldValue = FieldValue(value: "not a number" as String, fieldType: .double) - - #expect(fieldValue == nil) - } - - @Test("Double type with Int value returns nil") - func doubleTypeWithIntValueReturnsNil() { - let fieldValue = FieldValue(value: 42 as Int, fieldType: .double) - - #expect(fieldValue == nil) - } - - // MARK: - Timestamp/Date Type Tests - - @Test("Initialize FieldValue.date from Date value and timestamp type") - func initializeDateFromDateValue() { - let date = Date(timeIntervalSince1970: 1705315800) - let fieldValue = FieldValue(value: date, fieldType: .timestamp) - - #expect(fieldValue != nil) - if case .date(let value) = fieldValue { - #expect(value.timeIntervalSince1970 == 1705315800) - } else { - Issue.record("Expected .date case") - } - } - - @Test("Initialize FieldValue.date from epoch date") - func initializeDateFromEpochDate() { - let date = Date(timeIntervalSince1970: 0) - let fieldValue = FieldValue(value: date, fieldType: .timestamp) - - #expect(fieldValue != nil) - if case .date(let value) = fieldValue { - #expect(value.timeIntervalSince1970 == 0) - } else { - Issue.record("Expected .date case") - } - } - - @Test("Initialize FieldValue.date from current date") - func initializeDateFromCurrentDate() { - let date = Date() - let fieldValue = FieldValue(value: date, fieldType: .timestamp) - - #expect(fieldValue != nil) - if case .date(let value) = fieldValue { - #expect(value.timeIntervalSince1970 == date.timeIntervalSince1970) - } else { - Issue.record("Expected .date case") - } - } - - @Test("Timestamp type with non-Date value returns nil") - func timestampTypeWithNonDateValueReturnsNil() { - let fieldValue = FieldValue(value: "2024-01-15" as String, fieldType: .timestamp) - - #expect(fieldValue == nil) - } - - @Test("Timestamp type with Int value returns nil") - func timestampTypeWithIntValueReturnsNil() { - let fieldValue = FieldValue(value: 1705315800 as Int, fieldType: .timestamp) - - #expect(fieldValue == nil) - } - - // MARK: - Bytes Type Tests - - @Test("Initialize FieldValue.bytes from String value and bytes type") - func initializeBytesFromStringValue() { - let fieldValue = FieldValue(value: "base64data" as String, fieldType: .bytes) - - #expect(fieldValue != nil) - if case .bytes(let value) = fieldValue { - #expect(value == "base64data") - } else { - Issue.record("Expected .bytes case") - } - } - - @Test("Bytes type with non-String value returns nil") - func bytesTypeWithNonStringValueReturnsNil() { - let fieldValue = FieldValue(value: 42 as Int, fieldType: .bytes) - - #expect(fieldValue == nil) - } - - // MARK: - Unsupported Type Tests - - @Test("Asset type returns asset FieldValue") - func assetTypeReturnsNil() { - let fieldValue = FieldValue(value: "anything" as String, fieldType: .asset) - - #expect(fieldValue != nil) - if case .asset(let asset) = fieldValue { - #expect(asset.downloadURL == "anything") - } else { - Issue.record("Expected .asset case") - } - } - - @Test("Location type returns nil") - func locationTypeReturnsNil() { - let fieldValue = FieldValue(value: "anything" as String, fieldType: .location) - - #expect(fieldValue == nil) - } - - @Test("Reference type returns nil") - func referenceTypeReturnsNil() { - let fieldValue = FieldValue(value: "anything" as String, fieldType: .reference) - - #expect(fieldValue == nil) - } - - // MARK: - Invalid Type Conversion Tests - - @Test("Wrong type conversion returns nil (String as Int64)") - func wrongTypeConversionStringAsInt64() { - let fieldValue = FieldValue(value: "42" as String, fieldType: .int64) - - #expect(fieldValue == nil) - } - - @Test("Wrong type conversion returns nil (Int as String)") - func wrongTypeConversionIntAsString() { - let fieldValue = FieldValue(value: 42 as Int, fieldType: .string) - - #expect(fieldValue == nil) - } - - @Test("Wrong type conversion returns nil (Double as Int64)") - func wrongTypeConversionDoubleAsInt64() { - let fieldValue = FieldValue(value: 19.99 as Double, fieldType: .int64) - - #expect(fieldValue == nil) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Helpers/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/Helpers/MistDemoConfig+Testing.swift deleted file mode 100644 index 494a3213..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Helpers/MistDemoConfig+Testing.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// MistDemoConfig+Testing.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Configuration -import Foundation -import MistKit -@testable import MistDemo - -extension MistDemoConfig { - /// Create a test configuration with default values - init() async throws { - let configuration = try MistDemoConfiguration() - self = try await MistDemoConfig(configuration: configuration) - } - - /// Create a test configuration with custom values - init( - containerIdentifier: String = "iCloud.com.test.App", - apiToken: String = "test-api-token", - environment: MistKit.Environment = .development, - webAuthToken: String? = nil, - keyID: String? = nil, - privateKey: String? = nil, - privateKeyFile: String? = nil, - host: String = "127.0.0.1", - port: Int = 8080, - authTimeout: Double = 300, - skipAuth: Bool = false, - testAllAuth: Bool = false, - testApiOnly: Bool = false, - testAdaptive: Bool = false, - testServerToServer: Bool = false - ) async throws { - let envString = environment == .production ? "production" : "development" - - func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - - var values: [AbsoluteConfigKey: ConfigValue] = [ - key("container.identifier"): .init(stringLiteral: containerIdentifier), - key("api.token"): .init(stringLiteral: apiToken), - key("environment"): .init(stringLiteral: envString), - key("host"): .init(stringLiteral: host), - key("port"): .init(integerLiteral: port), - key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), - key("skip.auth"): .init(booleanLiteral: skipAuth), - key("test.all.auth"): .init(booleanLiteral: testAllAuth), - key("test.api.only"): .init(booleanLiteral: testApiOnly), - key("test.adaptive"): .init(booleanLiteral: testAdaptive), - key("test.server.to.server"): .init(booleanLiteral: testServerToServer) - ] - - if let webAuthToken { - values[key("web.auth.token")] = .init(stringLiteral: webAuthToken) - } - if let keyID { - values[key("key.id")] = .init(stringLiteral: keyID) - } - if let privateKey { - values[key("private.key")] = .init(stringLiteral: privateKey) - } - if let privateKeyFile { - values[key("private.key.file")] = .init(stringLiteral: privateKeyFile) - } - - let testProvider = InMemoryProvider(values: values) - let configuration = MistDemoConfiguration(testProvider: testProvider) - self = try await MistDemoConfig(configuration: configuration) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift new file mode 100644 index 00000000..75c90c3f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift @@ -0,0 +1,124 @@ +// +// MistDemoConfig+Testing.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Configuration +import Foundation +import MistKit + +@testable import MistDemoKit + +extension MistDemoConfig { + /// Create a test configuration with default values + internal init() async throws { + let configuration = try await MistDemoConfiguration() + self = try await MistDemoConfig(configuration: configuration) + } + + /// Create a test configuration that injects a raw `environment` + /// string into the underlying provider. Used to exercise the + /// env-validation logic with values the typed `environment:` + /// initializer cannot express (e.g. `"PRODUCTION"`, `"staging"`). + /// + /// Only the keys whose parsing this init aims to exercise are set; + /// `database` is left unset so it falls through to the production + /// parser's default and cannot affect environment-test semantics. + internal init(rawEnvironment: String) async throws { + func key(_ path: String) -> AbsoluteConfigKey { + AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) + } + + let testProvider = InMemoryProvider(values: [ + key("container.identifier"): .init(stringLiteral: "iCloud.com.test.App"), + key("api.token"): .init(stringLiteral: "test-api-token"), + key("environment"): .init(stringLiteral: rawEnvironment), + ]) + let configuration = MistDemoConfiguration(testProvider: testProvider) + self = try await MistDemoConfig(configuration: configuration) + } + + /// Create a test configuration with custom values + internal init( + containerIdentifier: String = "iCloud.com.test.App", + apiToken: String = "test-api-token", + environment: MistKit.Environment = .development, + database: MistKit.Database = .private, + webAuthToken: String? = nil, + keyID: String? = nil, + privateKey: String? = nil, + privateKeyFile: String? = nil, + host: String = "127.0.0.1", + port: Int = 8_080, + authTimeout: Double = 300, + skipAuth: Bool = false, + testAllAuth: Bool = false, + testApiOnly: Bool = false, + testAdaptive: Bool = false, + testServerToServer: Bool = false, + badCredentials: Bool = false + ) async throws { + let envString = environment == .production ? "production" : "development" + + func key(_ path: String) -> AbsoluteConfigKey { + AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) + } + + var values: [AbsoluteConfigKey: ConfigValue] = [ + key("container.identifier"): .init(stringLiteral: containerIdentifier), + key("api.token"): .init(stringLiteral: apiToken), + key("environment"): .init(stringLiteral: envString), + key("database"): .init(stringLiteral: database.pathSegment), + key("host"): .init(stringLiteral: host), + key("port"): .init(integerLiteral: port), + key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), + key("skip.auth"): .init(booleanLiteral: skipAuth), + key("test.all.auth"): .init(booleanLiteral: testAllAuth), + key("test.api.only"): .init(booleanLiteral: testApiOnly), + key("test.adaptive"): .init(booleanLiteral: testAdaptive), + key("test.server.to.server"): .init(booleanLiteral: testServerToServer), + key("bad.credentials"): .init(booleanLiteral: badCredentials), + ] + + if let webAuthToken { + values[key("web.auth.token")] = .init(stringLiteral: webAuthToken) + } + if let keyID { + values[key("key.id")] = .init(stringLiteral: keyID) + } + if let privateKey { + values[key("private.key")] = .init(stringLiteral: privateKey) + } + if let privateKeyFile { + values[key("private.key.file")] = .init(stringLiteral: privateKeyFile) + } + + let testProvider = InMemoryProvider(values: values) + let configuration = MistDemoConfiguration(testProvider: testProvider) + self = try await MistDemoConfig(configuration: configuration) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+Combination.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+Combination.swift new file mode 100644 index 00000000..f20f419a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+Combination.swift @@ -0,0 +1,64 @@ +// +// CSVEscaperTests+Combination.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Combination") + internal struct Combination { + private let escaper = CSVEscaper() + + @Test("String with comma and quote") + internal func commaAndQuote() { + let input = "Value, \"quoted\"" + let output = escaper.escape(input) + #expect(output == "\"Value, \"\"quoted\"\"\"") + } + + @Test("String with all special characters") + internal func allSpecialCharacters() { + let input = "Test,\"value\"\nwith\ttab\rand more" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + #expect(output.contains("\"\"value\"\"")) + } + + @Test("Complex RFC 4180 example") + internal func complexRFC4180() { + let input = "1997,Ford,E350,\"Super, \"\"luxurious\"\" truck\"" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+CommaEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+CommaEscaping.swift new file mode 100644 index 00000000..1adbcfc0 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+CommaEscaping.swift @@ -0,0 +1,68 @@ +// +// CSVEscaperTests+CommaEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Comma Escaping") + internal struct CommaEscaping { + private let escaper = CSVEscaper() + + @Test("String with comma is escaped and quoted") + internal func stringWithCommaIsEscaped() { + let input = "value1,value2" + let output = escaper.escape(input) + #expect(output == "\"value1,value2\"") + } + + @Test("String starting with comma is escaped") + internal func stringStartingWithComma() { + let input = ",leading" + let output = escaper.escape(input) + #expect(output == "\",leading\"") + } + + @Test("String ending with comma is escaped") + internal func stringEndingWithComma() { + let input = "trailing," + let output = escaper.escape(input) + #expect(output == "\"trailing,\"") + } + + @Test("String with multiple commas is escaped") + internal func stringWithMultipleCommas() { + let input = "a,b,c,d" + let output = escaper.escape(input) + #expect(output == "\"a,b,c,d\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+EdgeCases.swift new file mode 100644 index 00000000..ac26f84c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+EdgeCases.swift @@ -0,0 +1,77 @@ +// +// CSVEscaperTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Edge Cases") + internal struct EdgeCases { + private let escaper = CSVEscaper() + + @Test("String with only spaces") + internal func onlySpaces() { + let input = " " + let output = escaper.escape(input) + #expect(output == " ") + } + + @Test("Single character special") + internal func singleCharacterComma() { + let input = "," + let output = escaper.escape(input) + #expect(output == "\",\"") + } + + @Test("Single character quote") + internal func singleCharacterQuote() { + let input = "\"" + let output = escaper.escape(input) + #expect(output == "\"\"\"\"") + } + + @Test("Long string with no special characters") + internal func longPlainString() { + let input = String(repeating: "a", count: 1_000) + let output = escaper.escape(input) + #expect(output == input) + } + + @Test("Long string with commas") + internal func longStringWithCommas() { + let input = String(repeating: "a,", count: 100) + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + #expect(output.contains(",")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+NewlineEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+NewlineEscaping.swift new file mode 100644 index 00000000..b2574fb5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+NewlineEscaping.swift @@ -0,0 +1,68 @@ +// +// CSVEscaperTests+NewlineEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Newline Escaping") + internal struct NewlineEscaping { + private let escaper = CSVEscaper() + + @Test("String with newline is escaped and quoted") + internal func stringWithNewline() { + let input = "Line 1\nLine 2" + let output = escaper.escape(input) + #expect(output == "\"Line 1\nLine 2\"") + } + + @Test("String with carriage return is escaped") + internal func stringWithCarriageReturn() { + let input = "Before\rAfter" + let output = escaper.escape(input) + #expect(output == "\"Before\rAfter\"") + } + + @Test("String with CRLF is escaped") + internal func stringWithCRLF() { + let input = "Windows\r\nLine" + let output = escaper.escape(input) + #expect(output == "\"Windows\r\nLine\"") + } + + @Test("String with only newline") + internal func onlyNewline() { + let input = "\n" + let output = escaper.escape(input) + #expect(output == "\"\n\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+PlainString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+PlainString.swift new file mode 100644 index 00000000..9cb6fcc2 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+PlainString.swift @@ -0,0 +1,68 @@ +// +// CSVEscaperTests+PlainString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Plain String") + internal struct PlainString { + private let escaper = CSVEscaper() + + @Test("Plain string without special characters needs no escaping") + internal func plainStringNoEscaping() { + let input = "Hello World" + let output = escaper.escape(input) + #expect(output == "Hello World") + } + + @Test("Simple alphanumeric string needs no escaping") + internal func alphanumericNoEscaping() { + let input = "Test123" + let output = escaper.escape(input) + #expect(output == "Test123") + } + + @Test("String with spaces needs no escaping") + internal func stringWithSpacesNoEscaping() { + let input = "This is a test" + let output = escaper.escape(input) + #expect(output == "This is a test") + } + + @Test("Empty string needs no escaping") + internal func emptyStringNoEscaping() { + let input = "" + let output = escaper.escape(input) + #expect(output.isEmpty) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+QuoteEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+QuoteEscaping.swift new file mode 100644 index 00000000..453f7878 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+QuoteEscaping.swift @@ -0,0 +1,68 @@ +// +// CSVEscaperTests+QuoteEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Quote Escaping") + internal struct QuoteEscaping { + private let escaper = CSVEscaper() + + @Test("String with quote is escaped by doubling") + internal func stringWithQuoteIsDoubled() { + let input = "She said \"Hello\"" + let output = escaper.escape(input) + #expect(output == "\"She said \"\"Hello\"\"\"") + } + + @Test("String with single quote character") + internal func singleQuoteCharacter() { + let input = "\"quote\"" + let output = escaper.escape(input) + #expect(output == "\"\"\"quote\"\"\"") + } + + @Test("String with multiple quotes") + internal func multipleQuotes() { + let input = "\"Hello\" \"World\"" + let output = escaper.escape(input) + #expect(output == "\"\"\"Hello\"\" \"\"World\"\"\"") + } + + @Test("Empty quotes") + internal func emptyQuotes() { + let input = "\"\"" + let output = escaper.escape(input) + #expect(output == "\"\"\"\"\"\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+TabEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+TabEscaping.swift new file mode 100644 index 00000000..f7da7c3b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+TabEscaping.swift @@ -0,0 +1,54 @@ +// +// CSVEscaperTests+TabEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Tab Escaping") + internal struct TabEscaping { + private let escaper = CSVEscaper() + + @Test("String with tab is escaped and quoted") + internal func stringWithTab() { + let input = "Column1\tColumn2" + let output = escaper.escape(input) + #expect(output == "\"Column1\tColumn2\"") + } + + @Test("String with multiple tabs") + internal func stringWithMultipleTabs() { + let input = "A\tB\tC" + let output = escaper.escape(input) + #expect(output == "\"A\tB\tC\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+UnicodeAndEmoji.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+UnicodeAndEmoji.swift new file mode 100644 index 00000000..e0c937ab --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests+UnicodeAndEmoji.swift @@ -0,0 +1,68 @@ +// +// CSVEscaperTests+UnicodeAndEmoji.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CSVEscaperTests { + @Suite("Unicode and Emoji") + internal struct UnicodeAndEmoji { + private let escaper = CSVEscaper() + + @Test("String with emoji needs no escaping") + internal func stringWithEmoji() { + let input = "Hello 👋 World" + let output = escaper.escape(input) + #expect(output == "Hello 👋 World") + } + + @Test("String with emoji and comma is escaped") + internal func emojiWithComma() { + let input = "Test,👍" + let output = escaper.escape(input) + #expect(output == "\"Test,👍\"") + } + + @Test("String with unicode characters") + internal func unicodeCharacters() { + let input = "Café résumé" + let output = escaper.escape(input) + #expect(output == "Café résumé") + } + + @Test("String with Japanese characters") + internal func japaneseCharacters() { + let input = "こんにちは" + let output = escaper.escape(input) + #expect(output == "こんにちは") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests.swift new file mode 100644 index 00000000..63a892d9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaper/CSVEscaperTests.swift @@ -0,0 +1,33 @@ +// +// CSVEscaperTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CSVEscaper - RFC 4180 Compliance") +internal enum CSVEscaperTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaperTests.swift deleted file mode 100644 index 92624f24..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/CSVEscaperTests.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// CSVEscaperTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistDemo - -@Suite("CSVEscaper Tests - RFC 4180 Compliance") -struct CSVEscaperTests { - let escaper = CSVEscaper() - - // MARK: - Plain String Tests - - @Test("Plain string without special characters needs no escaping") - func plainStringNoEscaping() { - let input = "Hello World" - let output = escaper.escape(input) - #expect(output == "Hello World") - } - - @Test("Simple alphanumeric string needs no escaping") - func alphanumericNoEscaping() { - let input = "Test123" - let output = escaper.escape(input) - #expect(output == "Test123") - } - - @Test("String with spaces needs no escaping") - func stringWithSpacesNoEscaping() { - let input = "This is a test" - let output = escaper.escape(input) - #expect(output == "This is a test") - } - - @Test("Empty string needs no escaping") - func emptyStringNoEscaping() { - let input = "" - let output = escaper.escape(input) - #expect(output == "") - } - - // MARK: - Comma Escaping Tests - - @Test("String with comma is escaped and quoted") - func stringWithCommaIsEscaped() { - let input = "value1,value2" - let output = escaper.escape(input) - #expect(output == "\"value1,value2\"") - } - - @Test("String starting with comma is escaped") - func stringStartingWithComma() { - let input = ",leading" - let output = escaper.escape(input) - #expect(output == "\",leading\"") - } - - @Test("String ending with comma is escaped") - func stringEndingWithComma() { - let input = "trailing," - let output = escaper.escape(input) - #expect(output == "\"trailing,\"") - } - - @Test("String with multiple commas is escaped") - func stringWithMultipleCommas() { - let input = "a,b,c,d" - let output = escaper.escape(input) - #expect(output == "\"a,b,c,d\"") - } - - // MARK: - Quote Escaping Tests (RFC 4180) - - @Test("String with quote is escaped by doubling") - func stringWithQuoteIsDoubled() { - let input = "She said \"Hello\"" - let output = escaper.escape(input) - #expect(output == "\"She said \"\"Hello\"\"\"") - } - - @Test("String with single quote character") - func singleQuoteCharacter() { - let input = "\"quote\"" - let output = escaper.escape(input) - #expect(output == "\"\"\"quote\"\"\"") - } - - @Test("String with multiple quotes") - func multipleQuotes() { - let input = "\"Hello\" \"World\"" - let output = escaper.escape(input) - #expect(output == "\"\"\"Hello\"\" \"\"World\"\"\"") - } - - @Test("Empty quotes") - func emptyQuotes() { - let input = "\"\"" - let output = escaper.escape(input) - #expect(output == "\"\"\"\"\"\"") - } - - // MARK: - Newline Escaping Tests - - @Test("String with newline is escaped and quoted") - func stringWithNewline() { - let input = "Line 1\nLine 2" - let output = escaper.escape(input) - #expect(output == "\"Line 1\nLine 2\"") - } - - @Test("String with carriage return is escaped") - func stringWithCarriageReturn() { - let input = "Before\rAfter" - let output = escaper.escape(input) - #expect(output == "\"Before\rAfter\"") - } - - @Test("String with CRLF is escaped") - func stringWithCRLF() { - let input = "Windows\r\nLine" - let output = escaper.escape(input) - #expect(output == "\"Windows\r\nLine\"") - } - - @Test("String with only newline") - func onlyNewline() { - let input = "\n" - let output = escaper.escape(input) - #expect(output == "\"\n\"") - } - - // MARK: - Tab Escaping Tests - - @Test("String with tab is escaped and quoted") - func stringWithTab() { - let input = "Column1\tColumn2" - let output = escaper.escape(input) - #expect(output == "\"Column1\tColumn2\"") - } - - @Test("String with multiple tabs") - func stringWithMultipleTabs() { - let input = "A\tB\tC" - let output = escaper.escape(input) - #expect(output == "\"A\tB\tC\"") - } - - // MARK: - Combination Tests - - @Test("String with comma and quote") - func commaAndQuote() { - let input = "Value, \"quoted\"" - let output = escaper.escape(input) - #expect(output == "\"Value, \"\"quoted\"\"\"") - } - - @Test("String with all special characters") - func allSpecialCharacters() { - let input = "Test,\"value\"\nwith\ttab\rand more" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - #expect(output.contains("\"\"value\"\"")) - } - - @Test("Complex RFC 4180 example") - func complexRFC4180() { - let input = "1997,Ford,E350,\"Super, \"\"luxurious\"\" truck\"" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - } - - // MARK: - Unicode and Emoji Tests - - @Test("String with emoji needs no escaping") - func stringWithEmoji() { - let input = "Hello 👋 World" - let output = escaper.escape(input) - #expect(output == "Hello 👋 World") - } - - @Test("String with emoji and comma is escaped") - func emojiWithComma() { - let input = "Test,👍" - let output = escaper.escape(input) - #expect(output == "\"Test,👍\"") - } - - @Test("String with unicode characters") - func unicodeCharacters() { - let input = "Café résumé" - let output = escaper.escape(input) - #expect(output == "Café résumé") - } - - @Test("String with Japanese characters") - func japaneseCharacters() { - let input = "こんにちは" - let output = escaper.escape(input) - #expect(output == "こんにちは") - } - - // MARK: - Edge Cases - - @Test("String with only spaces") - func onlySpaces() { - let input = " " - let output = escaper.escape(input) - #expect(output == " ") - } - - @Test("Single character special") - func singleCharacterComma() { - let input = "," - let output = escaper.escape(input) - #expect(output == "\",\"") - } - - @Test("Single character quote") - func singleCharacterQuote() { - let input = "\"" - let output = escaper.escape(input) - #expect(output == "\"\"\"\"") - } - - @Test("Long string with no special characters") - func longPlainString() { - let input = String(repeating: "a", count: 1000) - let output = escaper.escape(input) - #expect(output == input) - } - - @Test("Long string with commas") - func longStringWithCommas() { - let input = String(repeating: "a,", count: 100) - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - #expect(output.contains(",")) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+BackslashEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+BackslashEscaping.swift new file mode 100644 index 00000000..e68ef8af --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+BackslashEscaping.swift @@ -0,0 +1,61 @@ +// +// JSONEscaperTests+BackslashEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Backslash Escaping") + internal struct BackslashEscaping { + private let escaper = JSONEscaper() + + @Test("Backslash is escaped") + internal func backslashEscaped() { + let input = "path\\to\\file" + let output = escaper.escape(input) + #expect(output == "path\\\\to\\\\file") + } + + @Test("Single backslash") + internal func singleBackslash() { + let input = "\\" + let output = escaper.escape(input) + #expect(output == "\\\\") + } + + @Test("Multiple consecutive backslashes") + internal func multipleBackslashes() { + let input = "\\\\\\" + let output = escaper.escape(input) + #expect(output == "\\\\\\\\\\\\") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+BackspaceEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+BackspaceEscaping.swift new file mode 100644 index 00000000..64f2d514 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+BackspaceEscaping.swift @@ -0,0 +1,47 @@ +// +// JSONEscaperTests+BackspaceEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Backspace Escaping") + internal struct BackspaceEscaping { + private let escaper = JSONEscaper() + + @Test("Backspace is escaped to \\b") + internal func backspaceEscaped() { + let input = "Before\u{0008}After" + let output = escaper.escape(input) + #expect(output == "Before\\bAfter") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+CarriageReturnEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+CarriageReturnEscaping.swift new file mode 100644 index 00000000..ab7f2ae4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+CarriageReturnEscaping.swift @@ -0,0 +1,54 @@ +// +// JSONEscaperTests+CarriageReturnEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Carriage Return Escaping") + internal struct CarriageReturnEscaping { + private let escaper = JSONEscaper() + + @Test("Carriage return is escaped to \\r") + internal func carriageReturnEscaped() { + let input = "Before\rAfter" + let output = escaper.escape(input) + #expect(output == "Before\\rAfter") + } + + @Test("CRLF is escaped") + internal func crlfEscaped() { + let input = "Windows\r\nLine" + let output = escaper.escape(input) + #expect(output == "Windows\\r\\nLine") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+Combination.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+Combination.swift new file mode 100644 index 00000000..08cd42d1 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+Combination.swift @@ -0,0 +1,64 @@ +// +// JSONEscaperTests+Combination.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Combination") + internal struct Combination { + private let escaper = JSONEscaper() + + @Test("Backslash and quote together") + internal func backslashAndQuote() { + let input = "path\\\"file\"" + let output = escaper.escape(input) + #expect(output == "path\\\\\\\"file\\\"") + } + + @Test("All escape characters together") + internal func allEscapeCharacters() { + let input = "\\\"\n\r\t\u{000C}\u{0008}" + let output = escaper.escape(input) + #expect(output == "\\\\\\\"\\n\\r\\t\\f\\b") + } + + @Test("Text with mixed escape sequences") + internal func mixedEscapeSequences() { + let input = "Line 1\nTab\there\r\nQuote:\"" + let output = escaper.escape(input) + #expect(output.contains("\\n")) + #expect(output.contains("\\t")) + #expect(output.contains("\\r")) + #expect(output.contains("\\\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+EdgeCases.swift new file mode 100644 index 00000000..dab84391 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+EdgeCases.swift @@ -0,0 +1,86 @@ +// +// JSONEscaperTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Edge Cases") + internal struct EdgeCases { + private let escaper = JSONEscaper() + + @Test("String with only escape characters") + internal func onlyEscapeCharacters() { + let input = "\n\r\t" + let output = escaper.escape(input) + #expect(output == "\\n\\r\\t") + } + + @Test("Long string with escapes") + internal func longStringWithEscapes() { + let input = String(repeating: "\n", count: 100) + let output = escaper.escape(input) + #expect(output == String(repeating: "\\n", count: 100)) + } + + @Test("Normal characters not escaped") + internal func normalCharactersNotEscaped() { + let input = "!@#$%^&*()_+-=[]{}|;':,.<>?/" + let output = escaper.escape(input) + // These characters should pass through unchanged + #expect(output == input) + } + + @Test("Escape sequence at start") + internal func escapeAtStart() { + let input = "\nStart" + let output = escaper.escape(input) + #expect(output == "\\nStart") + } + + @Test("Escape sequence at end") + internal func escapeAtEnd() { + let input = "End\n" + let output = escaper.escape(input) + #expect(output == "End\\n") + } + + @Test("Complex real-world JSON string") + internal func complexRealWorld() { + let input = "{\"key\": \"value\",\n\t\"nested\": {\"path\": \"C:\\\\Users\\\\test\"}}" + let output = escaper.escape(input) + #expect(output.contains("\\\"")) + #expect(output.contains("\\n")) + #expect(output.contains("\\t")) + #expect(output.contains("\\\\")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+FormFeedEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+FormFeedEscaping.swift new file mode 100644 index 00000000..02f07fc6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+FormFeedEscaping.swift @@ -0,0 +1,47 @@ +// +// JSONEscaperTests+FormFeedEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Form Feed Escaping") + internal struct FormFeedEscaping { + private let escaper = JSONEscaper() + + @Test("Form feed is escaped to \\f") + internal func formFeedEscaped() { + let input = "Before\u{000C}After" + let output = escaper.escape(input) + #expect(output == "Before\\fAfter") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+NewlineEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+NewlineEscaping.swift new file mode 100644 index 00000000..3e8e8645 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+NewlineEscaping.swift @@ -0,0 +1,61 @@ +// +// JSONEscaperTests+NewlineEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Newline Escaping") + internal struct NewlineEscaping { + private let escaper = JSONEscaper() + + @Test("Newline is escaped to \\n") + internal func newlineEscaped() { + let input = "Line 1\nLine 2" + let output = escaper.escape(input) + #expect(output == "Line 1\\nLine 2") + } + + @Test("Multiple newlines") + internal func multipleNewlines() { + let input = "A\nB\nC" + let output = escaper.escape(input) + #expect(output == "A\\nB\\nC") + } + + @Test("Consecutive newlines") + internal func consecutiveNewlines() { + let input = "Text\n\nMore" + let output = escaper.escape(input) + #expect(output == "Text\\n\\nMore") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+PlainString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+PlainString.swift new file mode 100644 index 00000000..819e3912 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+PlainString.swift @@ -0,0 +1,61 @@ +// +// JSONEscaperTests+PlainString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Plain String") + internal struct PlainString { + private let escaper = JSONEscaper() + + @Test("Plain string remains unchanged") + internal func plainStringUnchanged() { + let input = "Hello World" + let output = escaper.escape(input) + #expect(output == "Hello World") + } + + @Test("Alphanumeric string remains unchanged") + internal func alphanumericUnchanged() { + let input = "Test123" + let output = escaper.escape(input) + #expect(output == "Test123") + } + + @Test("Empty string remains empty") + internal func emptyStringRemains() { + let input = "" + let output = escaper.escape(input) + #expect(output.isEmpty) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+QuoteEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+QuoteEscaping.swift new file mode 100644 index 00000000..708d0525 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+QuoteEscaping.swift @@ -0,0 +1,61 @@ +// +// JSONEscaperTests+QuoteEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Quote Escaping") + internal struct QuoteEscaping { + private let escaper = JSONEscaper() + + @Test("Double quote is escaped") + internal func doubleQuoteEscaped() { + let input = "She said \"Hello\"" + let output = escaper.escape(input) + #expect(output == "She said \\\"Hello\\\"") + } + + @Test("Single double quote") + internal func singleQuote() { + let input = "\"" + let output = escaper.escape(input) + #expect(output == "\\\"") + } + + @Test("Multiple quotes") + internal func multipleQuotes() { + let input = "\"\"\"test\"\"\"" + let output = escaper.escape(input) + #expect(output == "\\\"\\\"\\\"test\\\"\\\"\\\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+TabEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+TabEscaping.swift new file mode 100644 index 00000000..42f86666 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+TabEscaping.swift @@ -0,0 +1,54 @@ +// +// JSONEscaperTests+TabEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Tab Escaping") + internal struct TabEscaping { + private let escaper = JSONEscaper() + + @Test("Tab is escaped to \\t") + internal func tabEscaped() { + let input = "Column1\tColumn2" + let output = escaper.escape(input) + #expect(output == "Column1\\tColumn2") + } + + @Test("Multiple tabs") + internal func multipleTabs() { + let input = "A\tB\tC" + let output = escaper.escape(input) + #expect(output == "A\\tB\\tC") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+UnicodeAndEmoji.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+UnicodeAndEmoji.swift new file mode 100644 index 00000000..871ed7ed --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests+UnicodeAndEmoji.swift @@ -0,0 +1,61 @@ +// +// JSONEscaperTests+UnicodeAndEmoji.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension JSONEscaperTests { + @Suite("Unicode and Emoji") + internal struct UnicodeAndEmoji { + private let escaper = JSONEscaper() + + @Test("Emoji preserved without escaping") + internal func emojiPreserved() { + let input = "Hello 👋 World" + let output = escaper.escape(input) + #expect(output == "Hello 👋 World") + } + + @Test("Unicode characters preserved") + internal func unicodePreserved() { + let input = "Café résumé" + let output = escaper.escape(input) + #expect(output == "Café résumé") + } + + @Test("Japanese characters preserved") + internal func japanesePreserved() { + let input = "こんにちは" + let output = escaper.escape(input) + #expect(output == "こんにちは") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests.swift new file mode 100644 index 00000000..45ae5f50 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaper/JSONEscaperTests.swift @@ -0,0 +1,33 @@ +// +// JSONEscaperTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("JSONEscaper - JSON String Escaping") +internal enum JSONEscaperTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaperTests.swift deleted file mode 100644 index cc33da37..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/JSONEscaperTests.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// JSONEscaperTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistDemo - -@Suite("JSONEscaper Tests - JSON String Escaping") -struct JSONEscaperTests { - let escaper = JSONEscaper() - - // MARK: - Plain String Tests - - @Test("Plain string remains unchanged") - func plainStringUnchanged() { - let input = "Hello World" - let output = escaper.escape(input) - #expect(output == "Hello World") - } - - @Test("Alphanumeric string remains unchanged") - func alphanumericUnchanged() { - let input = "Test123" - let output = escaper.escape(input) - #expect(output == "Test123") - } - - @Test("Empty string remains empty") - func emptyStringRemains() { - let input = "" - let output = escaper.escape(input) - #expect(output == "") - } - - // MARK: - Backslash Escaping Tests - - @Test("Backslash is escaped") - func backslashEscaped() { - let input = "path\\to\\file" - let output = escaper.escape(input) - #expect(output == "path\\\\to\\\\file") - } - - @Test("Single backslash") - func singleBackslash() { - let input = "\\" - let output = escaper.escape(input) - #expect(output == "\\\\") - } - - @Test("Multiple consecutive backslashes") - func multipleBackslashes() { - let input = "\\\\\\" - let output = escaper.escape(input) - #expect(output == "\\\\\\\\\\\\") - } - - // MARK: - Quote Escaping Tests - - @Test("Double quote is escaped") - func doubleQuoteEscaped() { - let input = "She said \"Hello\"" - let output = escaper.escape(input) - #expect(output == "She said \\\"Hello\\\"") - } - - @Test("Single double quote") - func singleQuote() { - let input = "\"" - let output = escaper.escape(input) - #expect(output == "\\\"") - } - - @Test("Multiple quotes") - func multipleQuotes() { - let input = "\"\"\"test\"\"\"" - let output = escaper.escape(input) - #expect(output == "\\\"\\\"\\\"test\\\"\\\"\\\"") - } - - // MARK: - Newline Escaping Tests - - @Test("Newline is escaped to \\n") - func newlineEscaped() { - let input = "Line 1\nLine 2" - let output = escaper.escape(input) - #expect(output == "Line 1\\nLine 2") - } - - @Test("Multiple newlines") - func multipleNewlines() { - let input = "A\nB\nC" - let output = escaper.escape(input) - #expect(output == "A\\nB\\nC") - } - - @Test("Consecutive newlines") - func consecutiveNewlines() { - let input = "Text\n\nMore" - let output = escaper.escape(input) - #expect(output == "Text\\n\\nMore") - } - - // MARK: - Carriage Return Escaping Tests - - @Test("Carriage return is escaped to \\r") - func carriageReturnEscaped() { - let input = "Before\rAfter" - let output = escaper.escape(input) - #expect(output == "Before\\rAfter") - } - - @Test("CRLF is escaped") - func crlfEscaped() { - let input = "Windows\r\nLine" - let output = escaper.escape(input) - #expect(output == "Windows\\r\\nLine") - } - - // MARK: - Tab Escaping Tests - - @Test("Tab is escaped to \\t") - func tabEscaped() { - let input = "Column1\tColumn2" - let output = escaper.escape(input) - #expect(output == "Column1\\tColumn2") - } - - @Test("Multiple tabs") - func multipleTabs() { - let input = "A\tB\tC" - let output = escaper.escape(input) - #expect(output == "A\\tB\\tC") - } - - // MARK: - Form Feed Escaping Tests - - @Test("Form feed is escaped to \\f") - func formFeedEscaped() { - let input = "Before\u{000C}After" - let output = escaper.escape(input) - #expect(output == "Before\\fAfter") - } - - // MARK: - Backspace Escaping Tests - - @Test("Backspace is escaped to \\b") - func backspaceEscaped() { - let input = "Before\u{0008}After" - let output = escaper.escape(input) - #expect(output == "Before\\bAfter") - } - - // MARK: - Combination Tests - - @Test("Backslash and quote together") - func backslashAndQuote() { - let input = "path\\\"file\"" - let output = escaper.escape(input) - #expect(output == "path\\\\\\\"file\\\"") - } - - @Test("All escape characters together") - func allEscapeCharacters() { - let input = "\\\"\n\r\t\u{000C}\u{0008}" - let output = escaper.escape(input) - #expect(output == "\\\\\\\"\\n\\r\\t\\f\\b") - } - - @Test("Text with mixed escape sequences") - func mixedEscapeSequences() { - let input = "Line 1\nTab\there\r\nQuote:\"" - let output = escaper.escape(input) - #expect(output.contains("\\n")) - #expect(output.contains("\\t")) - #expect(output.contains("\\r")) - #expect(output.contains("\\\"")) - } - - // MARK: - Unicode and Emoji Tests - - @Test("Emoji preserved without escaping") - func emojiPreserved() { - let input = "Hello 👋 World" - let output = escaper.escape(input) - #expect(output == "Hello 👋 World") - } - - @Test("Unicode characters preserved") - func unicodePreserved() { - let input = "Café résumé" - let output = escaper.escape(input) - #expect(output == "Café résumé") - } - - @Test("Japanese characters preserved") - func japanesePreserved() { - let input = "こんにちは" - let output = escaper.escape(input) - #expect(output == "こんにちは") - } - - // MARK: - Edge Cases - - @Test("String with only escape characters") - func onlyEscapeCharacters() { - let input = "\n\r\t" - let output = escaper.escape(input) - #expect(output == "\\n\\r\\t") - } - - @Test("Long string with escapes") - func longStringWithEscapes() { - let input = String(repeating: "\n", count: 100) - let output = escaper.escape(input) - #expect(output == String(repeating: "\\n", count: 100)) - } - - @Test("Normal characters not escaped") - func normalCharactersNotEscaped() { - let input = "!@#$%^&*()_+-=[]{}|;':,.<>?/" - let output = escaper.escape(input) - // These characters should pass through unchanged - #expect(output == input) - } - - @Test("Escape sequence at start") - func escapeAtStart() { - let input = "\nStart" - let output = escaper.escape(input) - #expect(output == "\\nStart") - } - - @Test("Escape sequence at end") - func escapeAtEnd() { - let input = "End\n" - let output = escaper.escape(input) - #expect(output == "End\\n") - } - - @Test("Complex real-world JSON string") - func complexRealWorld() { - let input = "{\"key\": \"value\",\n\t\"nested\": {\"path\": \"C:\\\\Users\\\\test\"}}" - let output = escaper.escape(input) - #expect(output.contains("\\\"")) - #expect(output.contains("\\n")) - #expect(output.contains("\\t")) - #expect(output.contains("\\\\")) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/OutputEscaperFactoryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/OutputEscaperFactoryTests.swift index 9d7949f1..33044de9 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/OutputEscaperFactoryTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/OutputEscaperFactoryTests.swift @@ -30,93 +30,93 @@ import Foundation import Testing -@testable import MistDemo +@testable import MistDemoKit @Suite("OutputEscaperFactory Tests") -struct OutputEscaperFactoryTests { - // MARK: - Factory Method Tests - - @Test("Factory returns CSVEscaper for CSV format") - func csvFormatReturnsCsvEscaper() { - let escaper = OutputEscaperFactory.escaper(for: .csv) - #expect(escaper is CSVEscaper) - } - - @Test("Factory returns YAMLEscaper for YAML format") - func yamlFormatReturnsYamlEscaper() { - let escaper = OutputEscaperFactory.escaper(for: .yaml) - #expect(escaper is YAMLEscaper) - } - - @Test("Factory returns JSONEscaper for JSON format") - func jsonFormatReturnsJsonEscaper() { - let escaper = OutputEscaperFactory.escaper(for: .json) - #expect(escaper is JSONEscaper) - } - - @Test("Factory returns TableEscaper for table format") - func tableFormatReturnsTableEscaper() { - let escaper = OutputEscaperFactory.escaper(for: .table) - #expect(escaper is TableEscaper) - } - - // MARK: - Functional Verification Tests - - @Test("CSV escaper handles commas correctly") - func csvEscaperHandlesCommas() { - let escaper = OutputEscaperFactory.escaper(for: .csv) - let result = escaper.escape("a,b,c") - #expect(result == "\"a,b,c\"") - } - - @Test("YAML escaper handles reserved words correctly") - func yamlEscaperHandlesReservedWords() { - let escaper = OutputEscaperFactory.escaper(for: .yaml) - let result = escaper.escape("yes") - #expect(result == "\"yes\"") - } - - @Test("JSON escaper handles quotes correctly") - func jsonEscaperHandlesQuotes() { - let escaper = OutputEscaperFactory.escaper(for: .json) - let result = escaper.escape("test\"value") - #expect(result.contains("\\\"")) - } - - @Test("Table escaper handles newlines correctly") - func tableEscaperHandlesNewlines() { - let escaper = OutputEscaperFactory.escaper(for: .table) - let result = escaper.escape("line1\nline2") - #expect(result == "line1 line2") - } - - // MARK: - All Format Coverage Tests - - @Test("Factory covers all OutputFormat cases") - func factoryCoversAllFormats() { - let allFormats = OutputFormat.allCases - #expect(allFormats.count == 4) - - for format in allFormats { - let escaper = OutputEscaperFactory.escaper(for: format) - // Verify each escaper is created successfully - let testString = "test" - let _ = escaper.escape(testString) - } - } - - @Test("Each format produces different escaper instance types") - func eachFormatProducesDifferentType() { - let csvEscaper = OutputEscaperFactory.escaper(for: .csv) - let yamlEscaper = OutputEscaperFactory.escaper(for: .yaml) - let jsonEscaper = OutputEscaperFactory.escaper(for: .json) - let tableEscaper = OutputEscaperFactory.escaper(for: .table) - - #expect(type(of: csvEscaper) != type(of: yamlEscaper)) - #expect(type(of: csvEscaper) != type(of: jsonEscaper)) - #expect(type(of: csvEscaper) != type(of: tableEscaper)) - #expect(type(of: yamlEscaper) != type(of: jsonEscaper)) - #expect(type(of: yamlEscaper) != type(of: tableEscaper)) - #expect(type(of: jsonEscaper) != type(of: tableEscaper)) +internal struct OutputEscaperFactoryTests { + // MARK: - Factory Method Tests + + @Test("Factory returns CSVEscaper for CSV format") + internal func csvFormatReturnsCsvEscaper() { + let escaper = OutputEscaperFactory.escaper(for: .csv) + #expect(escaper is CSVEscaper) + } + + @Test("Factory returns YAMLEscaper for YAML format") + internal func yamlFormatReturnsYamlEscaper() { + let escaper = OutputEscaperFactory.escaper(for: .yaml) + #expect(escaper is YAMLEscaper) + } + + @Test("Factory returns JSONEscaper for JSON format") + internal func jsonFormatReturnsJsonEscaper() { + let escaper = OutputEscaperFactory.escaper(for: .json) + #expect(escaper is JSONEscaper) + } + + @Test("Factory returns TableEscaper for table format") + internal func tableFormatReturnsTableEscaper() { + let escaper = OutputEscaperFactory.escaper(for: .table) + #expect(escaper is TableEscaper) + } + + // MARK: - Functional Verification Tests + + @Test("CSV escaper handles commas correctly") + internal func csvEscaperHandlesCommas() { + let escaper = OutputEscaperFactory.escaper(for: .csv) + let result = escaper.escape("a,b,c") + #expect(result == "\"a,b,c\"") + } + + @Test("YAML escaper handles reserved words correctly") + internal func yamlEscaperHandlesReservedWords() { + let escaper = OutputEscaperFactory.escaper(for: .yaml) + let result = escaper.escape("yes") + #expect(result == "\"yes\"") + } + + @Test("JSON escaper handles quotes correctly") + internal func jsonEscaperHandlesQuotes() { + let escaper = OutputEscaperFactory.escaper(for: .json) + let result = escaper.escape("test\"value") + #expect(result.contains("\\\"")) + } + + @Test("Table escaper handles newlines correctly") + internal func tableEscaperHandlesNewlines() { + let escaper = OutputEscaperFactory.escaper(for: .table) + let result = escaper.escape("line1\nline2") + #expect(result == "line1 line2") + } + + // MARK: - All Format Coverage Tests + + @Test("Factory covers all OutputFormat cases") + internal func factoryCoversAllFormats() { + let allFormats = OutputFormat.allCases + #expect(allFormats.count == 4) + + for format in allFormats { + let escaper = OutputEscaperFactory.escaper(for: format) + // Verify each escaper is created successfully + let testString = "test" + _ = escaper.escape(testString) } + } + + @Test("Each format produces different escaper instance types") + internal func eachFormatProducesDifferentType() { + let csvEscaper = OutputEscaperFactory.escaper(for: .csv) + let yamlEscaper = OutputEscaperFactory.escaper(for: .yaml) + let jsonEscaper = OutputEscaperFactory.escaper(for: .json) + let tableEscaper = OutputEscaperFactory.escaper(for: .table) + + #expect(type(of: csvEscaper) != type(of: yamlEscaper)) + #expect(type(of: csvEscaper) != type(of: jsonEscaper)) + #expect(type(of: csvEscaper) != type(of: tableEscaper)) + #expect(type(of: yamlEscaper) != type(of: jsonEscaper)) + #expect(type(of: yamlEscaper) != type(of: tableEscaper)) + #expect(type(of: jsonEscaper) != type(of: tableEscaper)) + } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+CarriageReturnConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+CarriageReturnConversion.swift new file mode 100644 index 00000000..ff8c0ca3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+CarriageReturnConversion.swift @@ -0,0 +1,61 @@ +// +// TableEscaperTests+CarriageReturnConversion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Carriage Return Conversion") + internal struct CarriageReturnConversion { + private let escaper = TableEscaper() + + @Test("Carriage return is converted to space") + internal func carriageReturnToSpace() { + let input = "Before\rAfter" + let output = escaper.escape(input) + #expect(output == "Before After") + } + + @Test("CRLF is converted to spaces") + internal func crlfToSpaces() { + let input = "Windows\r\nLine" + let output = escaper.escape(input) + #expect(output == "Windows Line") + } + + @Test("Multiple carriage returns") + internal func multipleCarriageReturns() { + let input = "A\rB\rC" + let output = escaper.escape(input) + #expect(output == "A B C") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+Combination.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+Combination.swift new file mode 100644 index 00000000..02df306c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+Combination.swift @@ -0,0 +1,68 @@ +// +// TableEscaperTests+Combination.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Combination") + internal struct Combination { + private let escaper = TableEscaper() + + @Test("Newlines, tabs, and spaces together") + internal func allWhitespaceTypes() { + let input = "A\nB\tC D" + let output = escaper.escape(input) + #expect(output == "A B C D") + } + + @Test("Complex multi-line with tabs") + internal func complexMultiLine() { + let input = "Line 1\n\tIndented\nLine 3" + let output = escaper.escape(input) + #expect(output == "Line 1 Indented Line 3") + } + + @Test("Mixed whitespace with trimming") + internal func mixedWithTrimming() { + let input = " \n Text \t " + let output = escaper.escape(input) + #expect(output == "Text") + } + + @Test("Internal spaces preserved") + internal func internalSpacesPreserved() { + let input = "Word1 Word2 Word3" + let output = escaper.escape(input) + #expect(output == "Word1 Word2 Word3") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+EdgeCases.swift new file mode 100644 index 00000000..00ef409a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+EdgeCases.swift @@ -0,0 +1,62 @@ +// +// TableEscaperTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Edge Cases") + internal struct EdgeCases { + private let escaper = TableEscaper() + + @Test("Very long multi-line string") + internal func longMultiLine() { + let input = String(repeating: "line\n", count: 100) + let output = escaper.escape(input) + #expect(!output.contains("\n")) + #expect(output.contains("line")) + } + + @Test("String with all whitespace types mixed") + internal func allWhitespaceTypesMixed() { + let input = " \n\t\r " + let output = escaper.escape(input) + #expect(output.isEmpty) + } + + @Test("Preserves special characters except whitespace") + internal func preservesSpecialChars() { + let input = "Test,with;special:chars" + let output = escaper.escape(input) + #expect(output == "Test,with;special:chars") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+NewlineConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+NewlineConversion.swift new file mode 100644 index 00000000..d45ac9b6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+NewlineConversion.swift @@ -0,0 +1,75 @@ +// +// TableEscaperTests+NewlineConversion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Newline Conversion") + internal struct NewlineConversion { + private let escaper = TableEscaper() + + @Test("Newline is converted to space") + internal func newlineToSpace() { + let input = "Line 1\nLine 2" + let output = escaper.escape(input) + #expect(output == "Line 1 Line 2") + } + + @Test("Multiple newlines are converted to spaces") + internal func multipleNewlinesToSpaces() { + let input = "A\nB\nC" + let output = escaper.escape(input) + #expect(output == "A B C") + } + + @Test("Consecutive newlines become consecutive spaces") + internal func consecutiveNewlines() { + let input = "Text\n\nMore" + let output = escaper.escape(input) + #expect(output == "Text More") + } + + @Test("String starting with newline") + internal func startingWithNewline() { + let input = "\nText" + let output = escaper.escape(input) + #expect(output == "Text") + } + + @Test("String ending with newline") + internal func endingWithNewline() { + let input = "Text\n" + let output = escaper.escape(input) + #expect(output == "Text") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+PlainString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+PlainString.swift new file mode 100644 index 00000000..e31c7cec --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+PlainString.swift @@ -0,0 +1,61 @@ +// +// TableEscaperTests+PlainString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Plain String") + internal struct PlainString { + private let escaper = TableEscaper() + + @Test("Plain string remains unchanged") + internal func plainStringUnchanged() { + let input = "Hello World" + let output = escaper.escape(input) + #expect(output == "Hello World") + } + + @Test("Alphanumeric string remains unchanged") + internal func alphanumericUnchanged() { + let input = "Test123" + let output = escaper.escape(input) + #expect(output == "Test123") + } + + @Test("Empty string remains empty") + internal func emptyStringRemains() { + let input = "" + let output = escaper.escape(input) + #expect(output.isEmpty) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+TabConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+TabConversion.swift new file mode 100644 index 00000000..d22f8f63 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+TabConversion.swift @@ -0,0 +1,61 @@ +// +// TableEscaperTests+TabConversion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Tab Conversion") + internal struct TabConversion { + private let escaper = TableEscaper() + + @Test("Tab is converted to space") + internal func tabToSpace() { + let input = "Column1\tColumn2" + let output = escaper.escape(input) + #expect(output == "Column1 Column2") + } + + @Test("Multiple tabs are converted to spaces") + internal func multipleTabs() { + let input = "A\tB\tC" + let output = escaper.escape(input) + #expect(output == "A B C") + } + + @Test("Consecutive tabs") + internal func consecutiveTabs() { + let input = "Text\t\tMore" + let output = escaper.escape(input) + #expect(output == "Text More") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+UnicodeAndEmoji.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+UnicodeAndEmoji.swift new file mode 100644 index 00000000..5dc343c7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+UnicodeAndEmoji.swift @@ -0,0 +1,61 @@ +// +// TableEscaperTests+UnicodeAndEmoji.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Unicode and Emoji") + internal struct UnicodeAndEmoji { + private let escaper = TableEscaper() + + @Test("Emoji preserved") + internal func emojiPreserved() { + let input = "Hello 👋 World" + let output = escaper.escape(input) + #expect(output == "Hello 👋 World") + } + + @Test("Emoji with newline") + internal func emojiWithNewline() { + let input = "Test 👍\nMore" + let output = escaper.escape(input) + #expect(output == "Test 👍 More") + } + + @Test("Unicode characters preserved") + internal func unicodePreserved() { + let input = "Café résumé" + let output = escaper.escape(input) + #expect(output == "Café résumé") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+WhitespaceTrimming.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+WhitespaceTrimming.swift new file mode 100644 index 00000000..420ba7e8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests+WhitespaceTrimming.swift @@ -0,0 +1,75 @@ +// +// TableEscaperTests+WhitespaceTrimming.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension TableEscaperTests { + @Suite("Whitespace Trimming") + internal struct WhitespaceTrimming { + private let escaper = TableEscaper() + + @Test("Leading whitespace is trimmed") + internal func leadingWhitespaceTrimmed() { + let input = " Text" + let output = escaper.escape(input) + #expect(output == "Text") + } + + @Test("Trailing whitespace is trimmed") + internal func trailingWhitespaceTrimmed() { + let input = "Text " + let output = escaper.escape(input) + #expect(output == "Text") + } + + @Test("Leading and trailing whitespace trimmed") + internal func bothSidesWhitespaceTrimmed() { + let input = " Text " + let output = escaper.escape(input) + #expect(output == "Text") + } + + @Test("String with only whitespace becomes empty") + internal func onlyWhitespace() { + let input = " " + let output = escaper.escape(input) + #expect(output.isEmpty) + } + + @Test("Newline-only string becomes empty") + internal func onlyNewlines() { + let input = "\n\n\n" + let output = escaper.escape(input) + #expect(output.isEmpty) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests.swift new file mode 100644 index 00000000..b36444d1 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaper/TableEscaperTests.swift @@ -0,0 +1,33 @@ +// +// TableEscaperTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("TableEscaper - Single-Line Conversion") +internal enum TableEscaperTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaperTests.swift deleted file mode 100644 index f6c44787..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/TableEscaperTests.swift +++ /dev/null @@ -1,258 +0,0 @@ -// -// TableEscaperTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistDemo - -@Suite("TableEscaper Tests - Single-Line Conversion") -struct TableEscaperTests { - let escaper = TableEscaper() - - // MARK: - Plain String Tests - - @Test("Plain string remains unchanged") - func plainStringUnchanged() { - let input = "Hello World" - let output = escaper.escape(input) - #expect(output == "Hello World") - } - - @Test("Alphanumeric string remains unchanged") - func alphanumericUnchanged() { - let input = "Test123" - let output = escaper.escape(input) - #expect(output == "Test123") - } - - @Test("Empty string remains empty") - func emptyStringRemains() { - let input = "" - let output = escaper.escape(input) - #expect(output == "") - } - - // MARK: - Newline Conversion Tests - - @Test("Newline is converted to space") - func newlineToSpace() { - let input = "Line 1\nLine 2" - let output = escaper.escape(input) - #expect(output == "Line 1 Line 2") - } - - @Test("Multiple newlines are converted to spaces") - func multipleNewlinesToSpaces() { - let input = "A\nB\nC" - let output = escaper.escape(input) - #expect(output == "A B C") - } - - @Test("Consecutive newlines become consecutive spaces") - func consecutiveNewlines() { - let input = "Text\n\nMore" - let output = escaper.escape(input) - #expect(output == "Text More") - } - - @Test("String starting with newline") - func startingWithNewline() { - let input = "\nText" - let output = escaper.escape(input) - #expect(output == "Text") - } - - @Test("String ending with newline") - func endingWithNewline() { - let input = "Text\n" - let output = escaper.escape(input) - #expect(output == "Text") - } - - // MARK: - Carriage Return Conversion Tests - - @Test("Carriage return is converted to space") - func carriageReturnToSpace() { - let input = "Before\rAfter" - let output = escaper.escape(input) - #expect(output == "Before After") - } - - @Test("CRLF is converted to spaces") - func crlfToSpaces() { - let input = "Windows\r\nLine" - let output = escaper.escape(input) - #expect(output == "Windows Line") - } - - @Test("Multiple carriage returns") - func multipleCarriageReturns() { - let input = "A\rB\rC" - let output = escaper.escape(input) - #expect(output == "A B C") - } - - // MARK: - Tab Conversion Tests - - @Test("Tab is converted to space") - func tabToSpace() { - let input = "Column1\tColumn2" - let output = escaper.escape(input) - #expect(output == "Column1 Column2") - } - - @Test("Multiple tabs are converted to spaces") - func multipleTabs() { - let input = "A\tB\tC" - let output = escaper.escape(input) - #expect(output == "A B C") - } - - @Test("Consecutive tabs") - func consecutiveTabs() { - let input = "Text\t\tMore" - let output = escaper.escape(input) - #expect(output == "Text More") - } - - // MARK: - Whitespace Trimming Tests - - @Test("Leading whitespace is trimmed") - func leadingWhitespaceTrimmed() { - let input = " Text" - let output = escaper.escape(input) - #expect(output == "Text") - } - - @Test("Trailing whitespace is trimmed") - func trailingWhitespaceTrimmed() { - let input = "Text " - let output = escaper.escape(input) - #expect(output == "Text") - } - - @Test("Leading and trailing whitespace trimmed") - func bothSidesWhitespaceTrimmed() { - let input = " Text " - let output = escaper.escape(input) - #expect(output == "Text") - } - - @Test("String with only whitespace becomes empty") - func onlyWhitespace() { - let input = " " - let output = escaper.escape(input) - #expect(output == "") - } - - @Test("Newline-only string becomes empty") - func onlyNewlines() { - let input = "\n\n\n" - let output = escaper.escape(input) - #expect(output == "") - } - - // MARK: - Combination Tests - - @Test("Newlines, tabs, and spaces together") - func allWhitespaceTypes() { - let input = "A\nB\tC D" - let output = escaper.escape(input) - #expect(output == "A B C D") - } - - @Test("Complex multi-line with tabs") - func complexMultiLine() { - let input = "Line 1\n\tIndented\nLine 3" - let output = escaper.escape(input) - #expect(output == "Line 1 Indented Line 3") - } - - @Test("Mixed whitespace with trimming") - func mixedWithTrimming() { - let input = " \n Text \t " - let output = escaper.escape(input) - #expect(output == "Text") - } - - @Test("Internal spaces preserved") - func internalSpacesPreserved() { - let input = "Word1 Word2 Word3" - let output = escaper.escape(input) - #expect(output == "Word1 Word2 Word3") - } - - // MARK: - Unicode and Emoji Tests - - @Test("Emoji preserved") - func emojiPreserved() { - let input = "Hello 👋 World" - let output = escaper.escape(input) - #expect(output == "Hello 👋 World") - } - - @Test("Emoji with newline") - func emojiWithNewline() { - let input = "Test 👍\nMore" - let output = escaper.escape(input) - #expect(output == "Test 👍 More") - } - - @Test("Unicode characters preserved") - func unicodePreserved() { - let input = "Café résumé" - let output = escaper.escape(input) - #expect(output == "Café résumé") - } - - // MARK: - Edge Cases - - @Test("Very long multi-line string") - func longMultiLine() { - let input = String(repeating: "line\n", count: 100) - let output = escaper.escape(input) - #expect(!output.contains("\n")) - #expect(output.contains("line")) - } - - @Test("String with all whitespace types mixed") - func allWhitespaceTypesMixed() { - let input = " \n\t\r " - let output = escaper.escape(input) - #expect(output == "") - } - - @Test("Preserves special characters except whitespace") - func preservesSpecialChars() { - let input = "Test,with;special:chars" - let output = escaper.escape(input) - #expect(output == "Test,with;special:chars") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+BooleanLikeString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+BooleanLikeString.swift new file mode 100644 index 00000000..2331694c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+BooleanLikeString.swift @@ -0,0 +1,96 @@ +// +// YAMLEscaperTests+BooleanLikeString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Boolean-like String (YAML Reserved Words)") + internal struct BooleanLikeString { + private let escaper = YAMLEscaper() + + @Test("String 'yes' is quoted") + internal func yesIsQuoted() { + let input = "yes" + let output = escaper.escape(input) + #expect(output == "\"yes\"") + } + + @Test("String 'no' is quoted") + internal func noIsQuoted() { + let input = "no" + let output = escaper.escape(input) + #expect(output == "\"no\"") + } + + @Test("String 'true' is quoted") + internal func trueIsQuoted() { + let input = "true" + let output = escaper.escape(input) + #expect(output == "\"true\"") + } + + @Test("String 'false' is quoted") + internal func falseIsQuoted() { + let input = "false" + let output = escaper.escape(input) + #expect(output == "\"false\"") + } + + @Test("String 'on' is quoted") + internal func onIsQuoted() { + let input = "on" + let output = escaper.escape(input) + #expect(output == "\"on\"") + } + + @Test("String 'off' is quoted") + internal func offIsQuoted() { + let input = "off" + let output = escaper.escape(input) + #expect(output == "\"off\"") + } + + @Test("String 'YES' (uppercase) is quoted") + internal func yesUppercaseIsQuoted() { + let input = "YES" + let output = escaper.escape(input) + #expect(output == "\"YES\"") + } + + @Test("String 'True' (capitalized) is quoted") + internal func trueCapitalizedIsQuoted() { + let input = "True" + let output = escaper.escape(input) + #expect(output == "\"True\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+ComplexEdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+ComplexEdgeCases.swift new file mode 100644 index 00000000..d30ccbf5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+ComplexEdgeCases.swift @@ -0,0 +1,83 @@ +// +// YAMLEscaperTests+ComplexEdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Complex Edge Cases") + internal struct ComplexEdgeCases { + private let escaper = YAMLEscaper() + + @Test("String that looks like YAML but isn't") + internal func yamlLikeString() { + let input = "yes this is true" + let output = escaper.escape(input) + // Should not be quoted because "yes" is part of a larger string + #expect(output == "yes this is true") + } + + @Test("Number within text is not escaped") + internal func numberWithinText() { + let input = "test123abc" + let output = escaper.escape(input) + #expect(output == "test123abc") + } + + @Test("String with special character in middle needs escaping") + internal func specialCharInMiddle() { + let input = "test:value" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + } + + @Test("Complex multi-line with quotes and escapes") + internal func complexMultiLine() { + let input = "Line 1: \"quoted\"\nLine 2: with\\backslash" + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + } + + @Test("Single newline character") + internal func singleNewline() { + let input = "\n" + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + } + + @Test("String with only whitespace and newline") + internal func whitespaceWithNewline() { + let input = " \n " + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+EmptyString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+EmptyString.swift new file mode 100644 index 00000000..145cda00 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+EmptyString.swift @@ -0,0 +1,47 @@ +// +// YAMLEscaperTests+EmptyString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Empty String") + internal struct EmptyString { + private let escaper = YAMLEscaper() + + @Test("Empty string is quoted") + internal func emptyStringIsQuoted() { + let input = "" + let output = escaper.escape(input) + #expect(output == "\"\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+MultiLineString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+MultiLineString.swift new file mode 100644 index 00000000..08e4fc45 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+MultiLineString.swift @@ -0,0 +1,76 @@ +// +// YAMLEscaperTests+MultiLineString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Multi-line String (Block Scalar)") + internal struct MultiLineString { + private let escaper = YAMLEscaper() + + @Test("Multi-line string uses block scalar") + internal func multiLineUsesBlockScalar() { + let input = "Line 1\nLine 2\nLine 3" + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + #expect(output.contains("Line 1")) + #expect(output.contains("Line 2")) + #expect(output.contains("Line 3")) + } + + @Test("Two-line string uses block scalar") + internal func twoLineUsesBlockScalar() { + let input = "First\nSecond" + let output = escaper.escape(input) + #expect(output.hasPrefix("|\n")) + } + + @Test("String with empty line in middle") + internal func multiLineWithEmptyLine() { + let input = "Before\n\nAfter" + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + #expect(output.contains("Before")) + #expect(output.contains("After")) + } + + @Test("Multi-line string preserves indentation context") + internal func multiLinePreservesContent() { + let input = "Line 1\nLine 2" + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + // Block scalar should have indented lines + #expect(output.contains(" Line 1")) + #expect(output.contains(" Line 2")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+NullLikeString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+NullLikeString.swift new file mode 100644 index 00000000..ba4d0eab --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+NullLikeString.swift @@ -0,0 +1,61 @@ +// +// YAMLEscaperTests+NullLikeString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Null-like String") + internal struct NullLikeString { + private let escaper = YAMLEscaper() + + @Test("String 'null' is quoted") + internal func nullIsQuoted() { + let input = "null" + let output = escaper.escape(input) + #expect(output == "\"null\"") + } + + @Test("String 'NULL' (uppercase) is quoted") + internal func nullUppercaseIsQuoted() { + let input = "NULL" + let output = escaper.escape(input) + #expect(output == "\"NULL\"") + } + + @Test("String '~' (tilde) is quoted") + internal func tildeIsQuoted() { + let input = "~" + let output = escaper.escape(input) + #expect(output == "\"~\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+NumericString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+NumericString.swift new file mode 100644 index 00000000..4e0dacd0 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+NumericString.swift @@ -0,0 +1,75 @@ +// +// YAMLEscaperTests+NumericString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Numeric String") + internal struct NumericString { + private let escaper = YAMLEscaper() + + @Test("Integer string is quoted") + internal func integerStringIsQuoted() { + let input = "123" + let output = escaper.escape(input) + #expect(output == "\"123\"") + } + + @Test("Negative integer string is quoted") + internal func negativeIntegerIsQuoted() { + let input = "-456" + let output = escaper.escape(input) + #expect(output == "\"-456\"") + } + + @Test("Float string is quoted") + internal func floatStringIsQuoted() { + let input = "3.14" + let output = escaper.escape(input) + #expect(output == "\"3.14\"") + } + + @Test("Scientific notation string is quoted") + internal func scientificNotationIsQuoted() { + let input = "1.23e10" + let output = escaper.escape(input) + #expect(output == "\"1.23e10\"") + } + + @Test("Zero string is quoted") + internal func zeroIsQuoted() { + let input = "0" + let output = escaper.escape(input) + #expect(output == "\"0\"") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+PlainString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+PlainString.swift new file mode 100644 index 00000000..981c529b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+PlainString.swift @@ -0,0 +1,54 @@ +// +// YAMLEscaperTests+PlainString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Plain String") + internal struct PlainString { + private let escaper = YAMLEscaper() + + @Test("Plain string without special characters needs no escaping") + internal func plainStringNoEscaping() { + let input = "Hello World" + let output = escaper.escape(input) + #expect(output == "Hello World") + } + + @Test("Simple alphanumeric string") + internal func alphanumericString() { + let input = "test123" + let output = escaper.escape(input) + #expect(output == "test123") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+QuoteAndBackslashEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+QuoteAndBackslashEscaping.swift new file mode 100644 index 00000000..2f7a4ebb --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+QuoteAndBackslashEscaping.swift @@ -0,0 +1,72 @@ +// +// YAMLEscaperTests+QuoteAndBackslashEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Quote and Backslash Escaping") + internal struct QuoteAndBackslashEscaping { + private let escaper = YAMLEscaper() + + @Test("String with double quote is escaped") + internal func stringWithDoubleQuote() { + let input = "She said \"Hello\"" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.contains("\\\"")) + } + + @Test("String with backslash is escaped") + internal func stringWithBackslash() { + let input = "path\\to\\file" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.contains("\\\\")) + } + + @Test("String with tab character is escaped in single-line mode") + internal func stringWithTabEscaped() { + let input = "before\tafter" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.contains("\\t")) + } + + @Test("String with carriage return is escaped in single-line mode") + internal func stringWithCarriageReturn() { + let input = "before\rafter" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.contains("\\r")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+SpecialCharacter.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+SpecialCharacter.swift new file mode 100644 index 00000000..d4efbb2c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+SpecialCharacter.swift @@ -0,0 +1,77 @@ +// +// YAMLEscaperTests+SpecialCharacter.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Special Character") + internal struct SpecialCharacter { + private let escaper = YAMLEscaper() + + @Test("String with colon is quoted") + internal func stringWithColon() { + let input = "key:value" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + } + + @Test("String starting with colon is quoted") + internal func stringStartingWithColon() { + let input = ":start" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + } + + @Test("String with hash is quoted") + internal func stringWithHash() { + let input = "comment # here" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + } + + @Test("String with brackets is quoted") + internal func stringWithBrackets() { + let input = "[array]" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + } + + @Test("String with braces is quoted") + internal func stringWithBraces() { + let input = "{object}" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+UnicodeAndEmoji.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+UnicodeAndEmoji.swift new file mode 100644 index 00000000..92c32e7d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+UnicodeAndEmoji.swift @@ -0,0 +1,61 @@ +// +// YAMLEscaperTests+UnicodeAndEmoji.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Unicode and Emoji") + internal struct UnicodeAndEmoji { + private let escaper = YAMLEscaper() + + @Test("String with emoji needs no escaping if plain") + internal func plainStringWithEmoji() { + let input = "Hello👋World" + let output = escaper.escape(input) + #expect(output == "Hello👋World") + } + + @Test("String with unicode characters") + internal func unicodeCharacters() { + let input = "Café" + let output = escaper.escape(input) + #expect(output == "Café") + } + + @Test("String with Japanese characters") + internal func japaneseCharacters() { + let input = "こんにちは" + let output = escaper.escape(input) + #expect(output == "こんにちは") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+Whitespace.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+Whitespace.swift new file mode 100644 index 00000000..01346e87 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests+Whitespace.swift @@ -0,0 +1,70 @@ +// +// YAMLEscaperTests+Whitespace.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension YAMLEscaperTests { + @Suite("Whitespace") + internal struct Whitespace { + private let escaper = YAMLEscaper() + + @Test("String starting with space is quoted") + internal func stringStartingWithSpace() { + let input = " leading" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + } + + @Test("String ending with space is quoted") + internal func stringEndingWithSpace() { + let input = "trailing " + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + #expect(output.hasSuffix("\"")) + } + + @Test("String starting with tab is quoted") + internal func stringStartingWithTab() { + let input = "\tleading" + let output = escaper.escape(input) + #expect(output.hasPrefix("\"")) + } + + @Test("String ending with newline is quoted") + internal func stringEndingWithNewline() { + let input = "trailing\n" + let output = escaper.escape(input) + #expect(output.hasPrefix("|")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests.swift new file mode 100644 index 00000000..754b46a8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaper/YAMLEscaperTests.swift @@ -0,0 +1,33 @@ +// +// YAMLEscaperTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("YAMLEscaper - YAML String Formatting") +internal enum YAMLEscaperTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaperTests.swift deleted file mode 100644 index aa781da7..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Escapers/YAMLEscaperTests.swift +++ /dev/null @@ -1,392 +0,0 @@ -// -// YAMLEscaperTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistDemo - -@Suite("YAMLEscaper Tests - YAML String Formatting") -struct YAMLEscaperTests { - let escaper = YAMLEscaper() - - // MARK: - Plain String Tests - - @Test("Plain string without special characters needs no escaping") - func plainStringNoEscaping() { - let input = "Hello World" - let output = escaper.escape(input) - #expect(output == "Hello World") - } - - @Test("Simple alphanumeric string") - func alphanumericString() { - let input = "test123" - let output = escaper.escape(input) - #expect(output == "test123") - } - - // MARK: - Empty String Tests - - @Test("Empty string is quoted") - func emptyStringIsQuoted() { - let input = "" - let output = escaper.escape(input) - #expect(output == "\"\"") - } - - // MARK: - Boolean-like String Tests (YAML Reserved Words) - - @Test("String 'yes' is quoted") - func yesIsQuoted() { - let input = "yes" - let output = escaper.escape(input) - #expect(output == "\"yes\"") - } - - @Test("String 'no' is quoted") - func noIsQuoted() { - let input = "no" - let output = escaper.escape(input) - #expect(output == "\"no\"") - } - - @Test("String 'true' is quoted") - func trueIsQuoted() { - let input = "true" - let output = escaper.escape(input) - #expect(output == "\"true\"") - } - - @Test("String 'false' is quoted") - func falseIsQuoted() { - let input = "false" - let output = escaper.escape(input) - #expect(output == "\"false\"") - } - - @Test("String 'on' is quoted") - func onIsQuoted() { - let input = "on" - let output = escaper.escape(input) - #expect(output == "\"on\"") - } - - @Test("String 'off' is quoted") - func offIsQuoted() { - let input = "off" - let output = escaper.escape(input) - #expect(output == "\"off\"") - } - - @Test("String 'YES' (uppercase) is quoted") - func yesUppercaseIsQuoted() { - let input = "YES" - let output = escaper.escape(input) - #expect(output == "\"YES\"") - } - - @Test("String 'True' (capitalized) is quoted") - func trueCapitalizedIsQuoted() { - let input = "True" - let output = escaper.escape(input) - #expect(output == "\"True\"") - } - - // MARK: - Null-like String Tests - - @Test("String 'null' is quoted") - func nullIsQuoted() { - let input = "null" - let output = escaper.escape(input) - #expect(output == "\"null\"") - } - - @Test("String 'NULL' (uppercase) is quoted") - func nullUppercaseIsQuoted() { - let input = "NULL" - let output = escaper.escape(input) - #expect(output == "\"NULL\"") - } - - @Test("String '~' (tilde) is quoted") - func tildeIsQuoted() { - let input = "~" - let output = escaper.escape(input) - #expect(output == "\"~\"") - } - - // MARK: - Numeric String Tests - - @Test("Integer string is quoted") - func integerStringIsQuoted() { - let input = "123" - let output = escaper.escape(input) - #expect(output == "\"123\"") - } - - @Test("Negative integer string is quoted") - func negativeIntegerIsQuoted() { - let input = "-456" - let output = escaper.escape(input) - #expect(output == "\"-456\"") - } - - @Test("Float string is quoted") - func floatStringIsQuoted() { - let input = "3.14" - let output = escaper.escape(input) - #expect(output == "\"3.14\"") - } - - @Test("Scientific notation string is quoted") - func scientificNotationIsQuoted() { - let input = "1.23e10" - let output = escaper.escape(input) - #expect(output == "\"1.23e10\"") - } - - @Test("Zero string is quoted") - func zeroIsQuoted() { - let input = "0" - let output = escaper.escape(input) - #expect(output == "\"0\"") - } - - // MARK: - Special Character Tests - - @Test("String with colon is quoted") - func stringWithColon() { - let input = "key:value" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - } - - @Test("String starting with colon is quoted") - func stringStartingWithColon() { - let input = ":start" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - } - - @Test("String with hash is quoted") - func stringWithHash() { - let input = "comment # here" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - } - - @Test("String with brackets is quoted") - func stringWithBrackets() { - let input = "[array]" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - } - - @Test("String with braces is quoted") - func stringWithBraces() { - let input = "{object}" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - } - - // MARK: - Whitespace Tests - - @Test("String starting with space is quoted") - func stringStartingWithSpace() { - let input = " leading" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - } - - @Test("String ending with space is quoted") - func stringEndingWithSpace() { - let input = "trailing " - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.hasSuffix("\"")) - } - - @Test("String starting with tab is quoted") - func stringStartingWithTab() { - let input = "\tleading" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - } - - @Test("String ending with newline is quoted") - func stringEndingWithNewline() { - let input = "trailing\n" - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - } - - // MARK: - Quote and Backslash Escaping Tests - - @Test("String with double quote is escaped") - func stringWithDoubleQuote() { - let input = "She said \"Hello\"" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.contains("\\\"")) - } - - @Test("String with backslash is escaped") - func stringWithBackslash() { - let input = "path\\to\\file" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.contains("\\\\")) - } - - @Test("String with tab character is escaped in single-line mode") - func stringWithTabEscaped() { - let input = "before\tafter" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.contains("\\t")) - } - - @Test("String with carriage return is escaped in single-line mode") - func stringWithCarriageReturn() { - let input = "before\rafter" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - #expect(output.contains("\\r")) - } - - // MARK: - Multi-line String Tests (Block Scalar) - - @Test("Multi-line string uses block scalar") - func multiLineUsesBlockScalar() { - let input = "Line 1\nLine 2\nLine 3" - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - #expect(output.contains("Line 1")) - #expect(output.contains("Line 2")) - #expect(output.contains("Line 3")) - } - - @Test("Two-line string uses block scalar") - func twoLineUsesBlockScalar() { - let input = "First\nSecond" - let output = escaper.escape(input) - #expect(output.hasPrefix("|\n")) - } - - @Test("String with empty line in middle") - func multiLineWithEmptyLine() { - let input = "Before\n\nAfter" - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - #expect(output.contains("Before")) - #expect(output.contains("After")) - } - - @Test("Multi-line string preserves indentation context") - func multiLinePreservesContent() { - let input = "Line 1\nLine 2" - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - // Block scalar should have indented lines - #expect(output.contains(" Line 1")) - #expect(output.contains(" Line 2")) - } - - // MARK: - Unicode and Emoji Tests - - @Test("String with emoji needs no escaping if plain") - func plainStringWithEmoji() { - let input = "Hello👋World" - let output = escaper.escape(input) - #expect(output == "Hello👋World") - } - - @Test("String with unicode characters") - func unicodeCharacters() { - let input = "Café" - let output = escaper.escape(input) - #expect(output == "Café") - } - - @Test("String with Japanese characters") - func japaneseCharacters() { - let input = "こんにちは" - let output = escaper.escape(input) - #expect(output == "こんにちは") - } - - // MARK: - Complex Edge Cases - - @Test("String that looks like YAML but isn't") - func yamlLikeString() { - let input = "yes this is true" - let output = escaper.escape(input) - // Should not be quoted because "yes" is part of a larger string - #expect(output == "yes this is true") - } - - @Test("Number within text is not escaped") - func numberWithinText() { - let input = "test123abc" - let output = escaper.escape(input) - #expect(output == "test123abc") - } - - @Test("String with special character in middle needs escaping") - func specialCharInMiddle() { - let input = "test:value" - let output = escaper.escape(input) - #expect(output.hasPrefix("\"")) - } - - @Test("Complex multi-line with quotes and escapes") - func complexMultiLine() { - let input = "Line 1: \"quoted\"\nLine 2: with\\backslash" - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - } - - @Test("Single newline character") - func singleNewline() { - let input = "\n" - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - } - - @Test("String with only whitespace and newline") - func whitespaceWithNewline() { - let input = " \n " - let output = escaper.escape(input) - #expect(output.hasPrefix("|")) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+CSVEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+CSVEscaping.swift new file mode 100644 index 00000000..ffcff412 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+CSVEscaping.swift @@ -0,0 +1,157 @@ +// +// CSVFormatterTests+CSVEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CSVFormatterTests { + @Suite("CSV Escaping") + internal struct CSVEscaping { + @Test("Format RecordInfo with comma in field value") + internal func formatRecordWithCommaInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Note", + fields: [ + "description": .string("Item one, item two, item three") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Value with comma should be quoted per RFC 4180 + #expect(output.contains("\"Item one, item two, item three\"")) + } + + @Test("Format RecordInfo with quote in field value") + internal func formatRecordWithQuoteInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Quote", + fields: [ + "text": .string("He said \"hello\" to me") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Quotes should be escaped by doubling them + #expect(output.contains("\"He said \"\"hello\"\" to me\"")) + } + + @Test("Format RecordInfo with newline in field value") + internal func formatRecordWithNewlineInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Text", + fields: [ + "content": .string("Line one\nLine two") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Newline should cause quoting + #expect(output.contains("\"Line one\nLine two\"")) + } + + @Test("Format RecordInfo with tab in field value") + internal func formatRecordWithTabInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Data", + fields: [ + "content": .string("Column1\tColumn2") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Tab should cause quoting + #expect(output.contains("\"Column1\tColumn2\"")) + } + + @Test("Format RecordInfo with multiple special characters") + internal func formatRecordWithMultipleSpecialChars() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Complex", + fields: [ + "data": .string("Value with \"quotes\", commas, and\nnewlines") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Should properly escape all special characters + #expect(output.contains("\"Value with \"\"quotes\"\", commas, and\nnewlines\"")) + } + + @Test("Format RecordInfo with simple value requiring no escaping") + internal func formatRecordWithSimpleValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Simple", + fields: [ + "title": .string("SimpleValue") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Simple value should not be quoted + #expect(output.contains("title,SimpleValue")) + #expect(!output.contains("\"SimpleValue\"")) + } + + @Test("Format RecordInfo name with special characters") + internal func formatRecordNameWithSpecialChars() throws { + let record = RecordInfo( + recordName: "record,with,commas", + recordType: "Type\"with\"quotes", + fields: [:] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("\"record,with,commas\"")) + #expect(output.contains("\"Type\"\"with\"\"quotes\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift new file mode 100644 index 00000000..e45568f1 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift @@ -0,0 +1,170 @@ +// +// CSVFormatterTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CSVFormatterTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("Format empty string values") + internal func formatEmptyStringValues() throws { + let record = RecordInfo( + recordName: "", + recordType: "", + fields: [ + "empty": .string("") + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Empty strings should still produce valid CSV + #expect(output.hasPrefix("Field,Value\n")) + } + + @Test("Format with complex field types") + internal func formatWithComplexFieldTypes() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Complex", + fields: [ + "reference": .reference(.init(recordName: "ref-001")), + "location": .location(.init(latitude: 37.7749, longitude: -122.4194)), + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Complex types should be converted to string representation + #expect(output.contains("location,")) + #expect(output.contains("reference,")) + } + + @Test("CSV output structure verification") + internal func verifyCSVStructure() throws { + let record = RecordInfo( + recordName: "verify-001", + recordType: "Verify", + fields: [ + "field1": .string("value1"), + "field2": .string("value2"), + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + + // Verify structure: header + recordName + recordType + fields + #expect(lines.count == 5) + #expect(lines[0] == "Field,Value") + } + + @Test("Format fallback to JSON for unknown type") + internal func formatUnknownType() throws { + struct UnknownType: Encodable { + let data: String + } + + let unknown = UnknownType(data: "test") + let formatter = CSVFormatter() + + let output = try formatter.format(unknown) + + // Should fall back to JSON format + #expect(output.contains("data")) + #expect(output.contains("test")) + } + + @Test("Format RecordInfo with list field") + internal func formatRecordWithListField() throws { + let record = RecordInfo( + recordName: "list-001", + recordType: "List", + fields: [ + "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("tags,")) + } + + @Test("Format RecordInfo with nil recordChangeTag") + internal func formatRecordWithNilChangeTag() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "NoTag", + recordChangeTag: nil, + fields: [:] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("recordName,rec-001")) + #expect(output.contains("recordType,NoTag")) + } + + @Test("RFC 4180 compliance verification") + internal func verifyRFC4180Compliance() throws { + let record = RecordInfo( + recordName: "rfc-test", + recordType: "RFC4180", + fields: [ + "standard": .string("normal"), + "comma": .string("a,b"), + "quote": .string("a\"b"), + "newline": .string("a\nb"), + "crlf": .string("a\r\nb"), + "complex": .string("a,\"b\"\nc"), + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + // Verify RFC 4180 compliance + #expect(output.contains("standard,normal")) + #expect(output.contains("\"a,b\"")) + #expect(output.contains("\"a\"\"b\"")) + #expect(output.contains("\"a\nb\"")) + #expect(output.contains("\"a\r\nb\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+RecordInfo.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+RecordInfo.swift new file mode 100644 index 00000000..0e198bbd --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+RecordInfo.swift @@ -0,0 +1,151 @@ +// +// CSVFormatterTests+RecordInfo.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CSVFormatterTests { + @Suite("RecordInfo") + internal struct RecordInfoFormat { + @Test("Format basic RecordInfo with CSV headers") + internal func formatBasicRecord() throws { + let record = RecordInfo( + recordName: "record-001", + recordType: "TodoItem", + recordChangeTag: "tag123", + fields: [:] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.hasPrefix("Field,Value\n")) + #expect(output.contains("recordName,record-001")) + #expect(output.contains("recordType,TodoItem")) + } + + @Test("Format RecordInfo with string fields") + internal func formatRecordWithStringFields() throws { + let record = RecordInfo( + recordName: "task-001", + recordType: "Task", + fields: [ + "title": .string("Buy groceries"), + "status": .string("pending"), + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("Field,Value\n")) + #expect(output.contains("status,")) + #expect(output.contains("title,")) + } + + @Test("Format RecordInfo with numeric fields") + internal func formatRecordWithNumericFields() throws { + let record = RecordInfo( + recordName: "item-001", + recordType: "Product", + fields: [ + "price": .double(19.99), + "quantity": .int64(42), + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("price,")) + #expect(output.contains("quantity,")) + } + + @Test("Format RecordInfo with sorted field names") + internal func formatRecordWithSortedFields() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Item", + fields: [ + "zebra": .string("last"), + "apple": .string("first"), + "middle": .string("between"), + ] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + let lines = output.components(separatedBy: "\n") + // Skip header, recordName, recordType + let fieldLines = lines.dropFirst(3).filter { !$0.isEmpty } + + // Fields should be sorted alphabetically + let fieldNames = fieldLines.compactMap { line -> String? in + line.components(separatedBy: ",").first + } + + #expect(fieldNames.contains("apple")) + #expect(fieldNames.contains("middle")) + #expect(fieldNames.contains("zebra")) + + // Verify order + if let appleIndex = fieldNames.firstIndex(of: "apple"), + let middleIndex = fieldNames.firstIndex(of: "middle"), + let zebraIndex = fieldNames.firstIndex(of: "zebra") + { + #expect(appleIndex < middleIndex) + #expect(middleIndex < zebraIndex) + } + } + + @Test("Format RecordInfo with empty fields") + internal func formatRecordWithEmptyFields() throws { + let record = RecordInfo( + recordName: "empty-001", + recordType: "Empty", + fields: [:] + ) + let formatter = CSVFormatter() + + let output = try formatter.format(record) + + #expect(output.hasPrefix("Field,Value\n")) + #expect(output.contains("recordName,empty-001")) + #expect(output.contains("recordType,Empty")) + + // Should only have header + 2 lines (recordName, recordType) + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + #expect(lines.count == 3) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+UserInfo.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+UserInfo.swift new file mode 100644 index 00000000..fa444ded --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+UserInfo.swift @@ -0,0 +1,120 @@ +// +// CSVFormatterTests+UserInfo.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension CSVFormatterTests { + @Suite("UserInfo") + internal struct UserInfoFormat { + @Test("Format basic UserInfo") + internal func formatBasicUser() throws { + let user = UserInfo.test( + userRecordName: "user-001", + firstName: "John", + lastName: "Doe", + emailAddress: "john.doe@example.com" + ) + let formatter = CSVFormatter() + + let output = try formatter.format(user) + + #expect(output.hasPrefix("Field,Value\n")) + #expect(output.contains("userRecordName,user-001")) + #expect(output.contains("firstName,John")) + #expect(output.contains("lastName,Doe")) + #expect(output.contains("emailAddress,john.doe@example.com")) + } + + @Test("Format UserInfo with minimal fields") + internal func formatUserWithMinimalFields() throws { + let user = UserInfo.test(userRecordName: "user-min") + let formatter = CSVFormatter() + + let output = try formatter.format(user) + + #expect(output.hasPrefix("Field,Value\n")) + #expect(output.contains("userRecordName,user-min")) + #expect(!output.contains("firstName")) + #expect(!output.contains("lastName")) + #expect(!output.contains("emailAddress")) + + // Should only have header + 1 line (userRecordName) + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + #expect(lines.count == 2) + } + + @Test("Format UserInfo with partial fields") + internal func formatUserWithPartialFields() throws { + let user = UserInfo.test( + userRecordName: "user-002", + firstName: "Jane" + ) + let formatter = CSVFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("userRecordName,user-002")) + #expect(output.contains("firstName,Jane")) + #expect(!output.contains("lastName")) + #expect(!output.contains("emailAddress")) + } + + @Test("Format UserInfo with special characters in name") + internal func formatUserWithSpecialCharsInName() throws { + let user = UserInfo.test( + userRecordName: "user-003", + firstName: "O'Brien", + lastName: "Smith, Jr." + ) + let formatter = CSVFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("firstName,O'Brien")) + #expect(output.contains("\"Smith, Jr.\"")) + } + + @Test("Format UserInfo with special characters in email") + internal func formatUserWithSpecialCharsInEmail() throws { + let user = UserInfo.test( + userRecordName: "user-004", + emailAddress: "test+tag@example.com" + ) + let formatter = CSVFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("emailAddress,test+tag@example.com")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests.swift new file mode 100644 index 00000000..6c8309f7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests.swift @@ -0,0 +1,33 @@ +// +// CSVFormatterTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("CSVFormatter") +internal enum CSVFormatterTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatterTests.swift deleted file mode 100644 index aea4d864..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatterTests.swift +++ /dev/null @@ -1,486 +0,0 @@ -// -// CSVFormatterTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit -import Testing - -@testable import MistDemo - -@Suite("CSVFormatter Tests") -struct CSVFormatterTests { - // MARK: - RecordInfo Tests - - @Test("Format basic RecordInfo with CSV headers") - func formatBasicRecord() throws { - let record = RecordInfo( - recordName: "record-001", - recordType: "TodoItem", - recordChangeTag: "tag123", - fields: [:] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.hasPrefix("Field,Value\n")) - #expect(output.contains("recordName,record-001")) - #expect(output.contains("recordType,TodoItem")) - } - - @Test("Format RecordInfo with string fields") - func formatRecordWithStringFields() throws { - let record = RecordInfo( - recordName: "task-001", - recordType: "Task", - fields: [ - "title": .string("Buy groceries"), - "status": .string("pending") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("Field,Value\n")) - #expect(output.contains("status,")) - #expect(output.contains("title,")) - } - - @Test("Format RecordInfo with numeric fields") - func formatRecordWithNumericFields() throws { - let record = RecordInfo( - recordName: "item-001", - recordType: "Product", - fields: [ - "price": .double(19.99), - "quantity": .int64(42) - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("price,")) - #expect(output.contains("quantity,")) - } - - @Test("Format RecordInfo with sorted field names") - func formatRecordWithSortedFields() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Item", - fields: [ - "zebra": .string("last"), - "apple": .string("first"), - "middle": .string("between") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - let lines = output.components(separatedBy: "\n") - // Skip header, recordName, recordType - let fieldLines = lines.dropFirst(3).filter { !$0.isEmpty } - - // Fields should be sorted alphabetically - let fieldNames = fieldLines.compactMap { line -> String? in - line.components(separatedBy: ",").first - } - - #expect(fieldNames.contains("apple")) - #expect(fieldNames.contains("middle")) - #expect(fieldNames.contains("zebra")) - - // Verify order - if let appleIndex = fieldNames.firstIndex(of: "apple"), - let middleIndex = fieldNames.firstIndex(of: "middle"), - let zebraIndex = fieldNames.firstIndex(of: "zebra") { - #expect(appleIndex < middleIndex) - #expect(middleIndex < zebraIndex) - } - } - - @Test("Format RecordInfo with empty fields") - func formatRecordWithEmptyFields() throws { - let record = RecordInfo( - recordName: "empty-001", - recordType: "Empty", - fields: [:] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.hasPrefix("Field,Value\n")) - #expect(output.contains("recordName,empty-001")) - #expect(output.contains("recordType,Empty")) - - // Should only have header + 2 lines (recordName, recordType) - let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } - #expect(lines.count == 3) - } - - // MARK: - CSV Escaping Tests - - @Test("Format RecordInfo with comma in field value") - func formatRecordWithCommaInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Note", - fields: [ - "description": .string("Item one, item two, item three") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Value with comma should be quoted per RFC 4180 - #expect(output.contains("\"Item one, item two, item three\"")) - } - - @Test("Format RecordInfo with quote in field value") - func formatRecordWithQuoteInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Quote", - fields: [ - "text": .string("He said \"hello\" to me") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Quotes should be escaped by doubling them - #expect(output.contains("\"He said \"\"hello\"\" to me\"")) - } - - @Test("Format RecordInfo with newline in field value") - func formatRecordWithNewlineInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Text", - fields: [ - "content": .string("Line one\nLine two") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Newline should cause quoting - #expect(output.contains("\"Line one\nLine two\"")) - } - - @Test("Format RecordInfo with tab in field value") - func formatRecordWithTabInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Data", - fields: [ - "content": .string("Column1\tColumn2") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Tab should cause quoting - #expect(output.contains("\"Column1\tColumn2\"")) - } - - @Test("Format RecordInfo with multiple special characters") - func formatRecordWithMultipleSpecialChars() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Complex", - fields: [ - "data": .string("Value with \"quotes\", commas, and\nnewlines") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Should properly escape all special characters - #expect(output.contains("\"Value with \"\"quotes\"\", commas, and\nnewlines\"")) - } - - @Test("Format RecordInfo with simple value requiring no escaping") - func formatRecordWithSimpleValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Simple", - fields: [ - "title": .string("SimpleValue") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Simple value should not be quoted - #expect(output.contains("title,SimpleValue")) - #expect(!output.contains("\"SimpleValue\"")) - } - - @Test("Format RecordInfo name with special characters") - func formatRecordNameWithSpecialChars() throws { - let record = RecordInfo( - recordName: "record,with,commas", - recordType: "Type\"with\"quotes", - fields: [:] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("\"record,with,commas\"")) - #expect(output.contains("\"Type\"\"with\"\"quotes\"")) - } - - // MARK: - UserInfo Tests - - @Test("Format basic UserInfo") - func formatBasicUser() throws { - let user = UserInfo.test( - userRecordName: "user-001", - firstName: "John", - lastName: "Doe", - emailAddress: "john.doe@example.com" - ) - let formatter = CSVFormatter() - - let output = try formatter.format(user) - - #expect(output.hasPrefix("Field,Value\n")) - #expect(output.contains("userRecordName,user-001")) - #expect(output.contains("firstName,John")) - #expect(output.contains("lastName,Doe")) - #expect(output.contains("emailAddress,john.doe@example.com")) - } - - @Test("Format UserInfo with minimal fields") - func formatUserWithMinimalFields() throws { - let user = UserInfo.test(userRecordName: "user-min") - let formatter = CSVFormatter() - - let output = try formatter.format(user) - - #expect(output.hasPrefix("Field,Value\n")) - #expect(output.contains("userRecordName,user-min")) - #expect(!output.contains("firstName")) - #expect(!output.contains("lastName")) - #expect(!output.contains("emailAddress")) - - // Should only have header + 1 line (userRecordName) - let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } - #expect(lines.count == 2) - } - - @Test("Format UserInfo with partial fields") - func formatUserWithPartialFields() throws { - let user = UserInfo.test( - userRecordName: "user-002", - firstName: "Jane" - ) - let formatter = CSVFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("userRecordName,user-002")) - #expect(output.contains("firstName,Jane")) - #expect(!output.contains("lastName")) - #expect(!output.contains("emailAddress")) - } - - @Test("Format UserInfo with special characters in name") - func formatUserWithSpecialCharsInName() throws { - let user = UserInfo.test( - userRecordName: "user-003", - firstName: "O'Brien", - lastName: "Smith, Jr." - ) - let formatter = CSVFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("firstName,O'Brien")) - #expect(output.contains("\"Smith, Jr.\"")) - } - - @Test("Format UserInfo with special characters in email") - func formatUserWithSpecialCharsInEmail() throws { - let user = UserInfo.test( - userRecordName: "user-004", - emailAddress: "test+tag@example.com" - ) - let formatter = CSVFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("emailAddress,test+tag@example.com")) - } - - // MARK: - Edge Cases - - @Test("Format empty string values") - func formatEmptyStringValues() throws { - let record = RecordInfo( - recordName: "", - recordType: "", - fields: [ - "empty": .string("") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Empty strings should still produce valid CSV - #expect(output.hasPrefix("Field,Value\n")) - } - - @Test("Format with complex field types") - func formatWithComplexFieldTypes() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Complex", - fields: [ - "reference": .reference(.init(recordName: "ref-001")), - "location": .location(.init(latitude: 37.7749, longitude: -122.4194)) - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Complex types should be converted to string representation - #expect(output.contains("location,")) - #expect(output.contains("reference,")) - } - - @Test("CSV output structure verification") - func verifyCSVStructure() throws { - let record = RecordInfo( - recordName: "verify-001", - recordType: "Verify", - fields: [ - "field1": .string("value1"), - "field2": .string("value2") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } - - // Verify structure: header + recordName + recordType + fields - #expect(lines.count == 5) - #expect(lines[0] == "Field,Value") - } - - @Test("Format fallback to JSON for unknown type") - func formatUnknownType() throws { - struct UnknownType: Encodable { - let data: String - } - - let unknown = UnknownType(data: "test") - let formatter = CSVFormatter() - - let output = try formatter.format(unknown) - - // Should fall back to JSON format - #expect(output.contains("data")) - #expect(output.contains("test")) - } - - @Test("Format RecordInfo with list field") - func formatRecordWithListField() throws { - let record = RecordInfo( - recordName: "list-001", - recordType: "List", - fields: [ - "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("tags,")) - } - - @Test("Format RecordInfo with nil recordChangeTag") - func formatRecordWithNilChangeTag() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "NoTag", - recordChangeTag: nil, - fields: [:] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("recordName,rec-001")) - #expect(output.contains("recordType,NoTag")) - } - - @Test("RFC 4180 compliance verification") - func verifyRFC4180Compliance() throws { - let record = RecordInfo( - recordName: "rfc-test", - recordType: "RFC4180", - fields: [ - "standard": .string("normal"), - "comma": .string("a,b"), - "quote": .string("a\"b"), - "newline": .string("a\nb"), - "crlf": .string("a\r\nb"), - "complex": .string("a,\"b\"\nc") - ] - ) - let formatter = CSVFormatter() - - let output = try formatter.format(record) - - // Verify RFC 4180 compliance - #expect(output.contains("standard,normal")) - #expect(output.contains("\"a,b\"")) - #expect(output.contains("\"a\"\"b\"")) - #expect(output.contains("\"a\nb\"")) - #expect(output.contains("\"a\r\nb\"")) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+EdgeCases.swift new file mode 100644 index 00000000..b4e9641c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+EdgeCases.swift @@ -0,0 +1,123 @@ +// +// OutputFormatterFactoryTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension OutputFormatterFactoryTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("Formatters handle Unicode characters") + internal func formattersHandleUnicode() throws { + let record = RecordInfo( + recordName: "unicode-001", + recordType: "Unicode", + fields: [ + "emoji": .string("😀🎉✨"), + "chinese": .string("你好世界"), + "arabic": .string("مرحبا"), + "accents": .string("café résumé"), + ] + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(record) + + #expect(!output.isEmpty) + } + } + + @Test("Formatters handle very long strings") + internal func formattersHandleVeryLongStrings() throws { + let longString = String(repeating: "a", count: 10_000) + let record = RecordInfo( + recordName: "long-001", + recordType: "Long", + fields: ["long": .string(longString)] + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(record) + + #expect(!output.isEmpty) + #expect(output.count >= longString.count) + } + } + + @Test("Formatters handle many fields") + internal func formattersHandleManyFields() throws { + var fields: [String: FieldValue] = [:] + for index in 0..<100 { + fields["field\(index)"] = .string("value\(index)") + } + + let record = RecordInfo( + recordName: "many-001", + recordType: "Many", + fields: fields + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(record) + + #expect(!output.isEmpty) + } + } + + @Test("Default pretty parameter is false") + internal func defaultPrettyParameterIsFalse() throws { + let record = RecordInfo( + recordName: "default-001", + recordType: "Default", + fields: ["field": .string("value")] + ) + + // Call without pretty parameter (defaults to false) + let defaultFormatter = OutputFormatterFactory.formatter(for: .json) + let defaultOutput = try defaultFormatter.format(record) + + // Call with explicit pretty: false + let explicitFormatter = OutputFormatterFactory.formatter(for: .json, pretty: false) + let explicitOutput = try explicitFormatter.format(record) + + // Both should produce compact output (single line) + let defaultLines = defaultOutput.components(separatedBy: "\n").filter { !$0.isEmpty } + let explicitLines = explicitOutput.components(separatedBy: "\n").filter { !$0.isEmpty } + + #expect(defaultLines.count == 1) + #expect(explicitLines.count == 1) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FactoryCreation.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FactoryCreation.swift new file mode 100644 index 00000000..3a2e2c3f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FactoryCreation.swift @@ -0,0 +1,101 @@ +// +// OutputFormatterFactoryTests+FactoryCreation.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension OutputFormatterFactoryTests { + @Suite("Factory Creation") + internal struct FactoryCreation { + @Test("Create JSON formatter") + internal func createJSONFormatter() { + let formatter = OutputFormatterFactory.formatter(for: .json, pretty: false) + + #expect(formatter is JSONFormatter) + } + + @Test("Create pretty JSON formatter") + internal func createPrettyJSONFormatter() { + let formatter = OutputFormatterFactory.formatter(for: .json, pretty: true) + + #expect(formatter is JSONFormatter) + } + + @Test("Create CSV formatter") + internal func createCSVFormatter() { + let formatter = OutputFormatterFactory.formatter(for: .csv, pretty: false) + + #expect(formatter is CSVFormatter) + } + + @Test("Create Table formatter") + internal func createTableFormatter() { + let formatter = OutputFormatterFactory.formatter(for: .table, pretty: false) + + #expect(formatter is TableFormatter) + } + + @Test("Create YAML formatter") + internal func createYAMLFormatter() { + let formatter = OutputFormatterFactory.formatter(for: .yaml, pretty: false) + + #expect(formatter is YAMLFormatter) + } + + @Test("Pretty flag ignored for CSV formatter") + internal func prettyFlagIgnoredForCSV() { + let formatter1 = OutputFormatterFactory.formatter(for: .csv, pretty: false) + let formatter2 = OutputFormatterFactory.formatter(for: .csv, pretty: true) + + #expect(formatter1 is CSVFormatter) + #expect(formatter2 is CSVFormatter) + } + + @Test("Pretty flag ignored for Table formatter") + internal func prettyFlagIgnoredForTable() { + let formatter1 = OutputFormatterFactory.formatter(for: .table, pretty: false) + let formatter2 = OutputFormatterFactory.formatter(for: .table, pretty: true) + + #expect(formatter1 is TableFormatter) + #expect(formatter2 is TableFormatter) + } + + @Test("Pretty flag ignored for YAML formatter") + internal func prettyFlagIgnoredForYAML() { + let formatter1 = OutputFormatterFactory.formatter(for: .yaml, pretty: false) + let formatter2 = OutputFormatterFactory.formatter(for: .yaml, pretty: true) + + #expect(formatter1 is YAMLFormatter) + #expect(formatter2 is YAMLFormatter) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatSpecificOutput.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatSpecificOutput.swift new file mode 100644 index 00000000..b32d218c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatSpecificOutput.swift @@ -0,0 +1,107 @@ +// +// OutputFormatterFactoryTests+FormatSpecificOutput.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension OutputFormatterFactoryTests { + @Suite("Format-Specific Output") + internal struct FormatSpecificOutput { + @Test("JSON formatter produces valid JSON") + internal func jsonFormatterProducesValidJSON() throws { + let formatter = OutputFormatterFactory.formatter(for: .json, pretty: false) + let record = RecordInfo( + recordName: "test-001", + recordType: "Test", + fields: ["field": .string("value")] + ) + + let output = try formatter.format(record) + + // Should be valid JSON + #expect(output.contains("{")) + #expect(output.contains("}")) + #expect(output.contains("\"")) + + // Should be parseable as JSON + let data = Data(output.utf8) + _ = try JSONSerialization.jsonObject(with: data) + } + + @Test("CSV formatter produces CSV with headers") + internal func csvFormatterProducesCSVWithHeaders() throws { + let formatter = OutputFormatterFactory.formatter(for: .csv, pretty: false) + let record = RecordInfo( + recordName: "test-001", + recordType: "Test", + fields: ["field": .string("value")] + ) + + let output = try formatter.format(record) + + // Should have CSV header + #expect(output.hasPrefix("Field,Value\n")) + } + + @Test("Table formatter produces human-readable output") + internal func tableFormatterProducesReadableOutput() throws { + let formatter = OutputFormatterFactory.formatter(for: .table, pretty: false) + let record = RecordInfo( + recordName: "test-001", + recordType: "Test", + fields: ["field": .string("value")] + ) + + let output = try formatter.format(record) + + // Should have human-readable labels + #expect(output.contains("Record Name:")) + #expect(output.contains("Record Type:")) + } + + @Test("YAML formatter produces YAML structure") + internal func yamlFormatterProducesYAMLStructure() throws { + let formatter = OutputFormatterFactory.formatter(for: .yaml, pretty: false) + let record = RecordInfo( + recordName: "test-001", + recordType: "Test", + fields: ["field": .string("value")] + ) + + let output = try formatter.format(record) + + // Should have YAML structure + #expect(output.contains("recordName:")) + #expect(output.contains("recordType:")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift new file mode 100644 index 00000000..4e391c55 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift @@ -0,0 +1,140 @@ +// +// OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension OutputFormatterFactoryTests { + @Suite("Formatter Behavior Consistency") + internal struct FormatterBehaviorConsistency { + @Test("All formatters handle empty fields") + internal func allFormattersHandleEmptyFields() throws { + let record = RecordInfo( + recordName: "empty-001", + recordType: "Empty", + fields: [:] + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(record) + + #expect(!output.isEmpty) + #expect(output.contains("empty-001")) + } + } + + @Test("All formatters handle special characters") + internal func allFormattersHandleSpecialCharacters() throws { + let record = RecordInfo( + recordName: "special-001", + recordType: "Special", + fields: [ + "quotes": .string("He said \"hello\""), + "newlines": .string("Line1\nLine2"), + "commas": .string("a,b,c"), + ] + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + + // Should not throw + let output = try formatter.format(record) + #expect(!output.isEmpty) + } + } + + @Test("All formatters handle complex field types") + internal func allFormattersHandleComplexFieldTypes() throws { + let record = RecordInfo( + recordName: "complex-001", + recordType: "Complex", + fields: [ + "reference": .reference(.init(recordName: "ref-001")), + "location": .location(.init(latitude: 37.7749, longitude: -122.4194)), + "list": .list([.string("item1"), .string("item2")]), + ] + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + + // Should not throw + let output = try formatter.format(record) + #expect(!output.isEmpty) + } + } + + @Test("All formatters handle minimal UserInfo") + internal func allFormattersHandleMinimalUserInfo() throws { + let user = UserInfo.test(userRecordName: "user-min") + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(user) + + #expect(!output.isEmpty) + #expect(output.contains("user-min")) + } + } + + @Test("Factory produces working formatters for all formats") + internal func factoryProducesWorkingFormatters() throws { + let testData = RecordInfo( + recordName: "test", + recordType: "Test", + fields: ["key": .string("value")] + ) + + // JSON + let jsonFormatter = OutputFormatterFactory.formatter(for: .json) + let jsonOutput = try jsonFormatter.format(testData) + #expect(jsonOutput.contains("test")) + + // CSV + let csvFormatter = OutputFormatterFactory.formatter(for: .csv) + let csvOutput = try csvFormatter.format(testData) + #expect(csvOutput.contains("Field,Value")) + + // Table + let tableFormatter = OutputFormatterFactory.formatter(for: .table) + let tableOutput = try tableFormatter.format(testData) + #expect(tableOutput.contains("Record Name:")) + + // YAML + let yamlFormatter = OutputFormatterFactory.formatter(for: .yaml) + let yamlOutput = try yamlFormatter.format(testData) + #expect(yamlOutput.contains("recordName:")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+Integration.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+Integration.swift new file mode 100644 index 00000000..df200564 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+Integration.swift @@ -0,0 +1,119 @@ +// +// OutputFormatterFactoryTests+Integration.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension OutputFormatterFactoryTests { + @Suite("Integration") + internal struct Integration { + @Test("All formatters can format RecordInfo") + internal func allFormattersCanFormatRecordInfo() throws { + let record = RecordInfo( + recordName: "integration-001", + recordType: "Integration", + fields: [ + "string": .string("test"), + "number": .int64(42), + ] + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(record) + + #expect(!output.isEmpty) + } + } + + @Test("All formatters can format UserInfo") + internal func allFormattersCanFormatUserInfo() throws { + let user = UserInfo.test( + userRecordName: "user-001", + firstName: "Test", + lastName: "User", + emailAddress: "test@example.com" + ) + + for format in OutputFormat.allCases { + let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) + let output = try formatter.format(user) + + #expect(!output.isEmpty) + } + } + + @Test("Different formatters produce different output") + internal func differentFormattersProduceDifferentOutput() throws { + let record = RecordInfo( + recordName: "diff-001", + recordType: "Diff", + fields: ["field": .string("value")] + ) + + let jsonOutput = try OutputFormatterFactory.formatter(for: .json, pretty: false) + .format(record) + let csvOutput = try OutputFormatterFactory.formatter(for: .csv, pretty: false) + .format(record) + let tableOutput = try OutputFormatterFactory.formatter(for: .table, pretty: false) + .format(record) + let yamlOutput = try OutputFormatterFactory.formatter(for: .yaml, pretty: false) + .format(record) + + // All outputs should be different + #expect(jsonOutput != csvOutput) + #expect(jsonOutput != tableOutput) + #expect(jsonOutput != yamlOutput) + #expect(csvOutput != tableOutput) + #expect(csvOutput != yamlOutput) + #expect(tableOutput != yamlOutput) + } + + @Test("JSON pretty vs compact produces different output") + internal func jsonPrettyVsCompactDifferent() throws { + let record = RecordInfo( + recordName: "pretty-001", + recordType: "Pretty", + fields: ["field": .string("value")] + ) + + let prettyOutput = try OutputFormatterFactory.formatter(for: .json, pretty: true) + .format(record) + let compactOutput = try OutputFormatterFactory.formatter(for: .json, pretty: false) + .format(record) + + // Pretty should have more whitespace + #expect(prettyOutput.count > compactOutput.count) + #expect(prettyOutput.contains("\n")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+OutputFormatEnum.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+OutputFormatEnum.swift new file mode 100644 index 00000000..f3c72d3f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+OutputFormatEnum.swift @@ -0,0 +1,89 @@ +// +// OutputFormatterFactoryTests+OutputFormatEnum.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension OutputFormatterFactoryTests { + @Suite("OutputFormat Enum") + internal struct OutputFormatEnum { + @Test("OutputFormat case count") + internal func outputFormatCaseCount() { + let allCases = OutputFormat.allCases + + #expect(allCases.count == 4) + #expect(allCases.contains(.json)) + #expect(allCases.contains(.csv)) + #expect(allCases.contains(.table)) + #expect(allCases.contains(.yaml)) + } + + @Test("OutputFormat raw values") + internal func outputFormatRawValues() { + #expect(OutputFormat.json.rawValue == "json") + #expect(OutputFormat.csv.rawValue == "csv") + #expect(OutputFormat.table.rawValue == "table") + #expect(OutputFormat.yaml.rawValue == "yaml") + } + + @Test("OutputFormat from raw value") + internal func outputFormatFromRawValue() { + #expect(OutputFormat(rawValue: "json") == .json) + #expect(OutputFormat(rawValue: "csv") == .csv) + #expect(OutputFormat(rawValue: "table") == .table) + #expect(OutputFormat(rawValue: "yaml") == .yaml) + #expect(OutputFormat(rawValue: "invalid") == nil) + } + + @Test("OutputFormat createFormatter method") + internal func outputFormatCreateFormatter() { + let jsonFormatter = OutputFormat.json.createFormatter(pretty: false) + let csvFormatter = OutputFormat.csv.createFormatter() + let tableFormatter = OutputFormat.table.createFormatter() + let yamlFormatter = OutputFormat.yaml.createFormatter() + + #expect(jsonFormatter is JSONFormatter) + #expect(csvFormatter is CSVFormatter) + #expect(tableFormatter is TableFormatter) + #expect(yamlFormatter is YAMLFormatter) + } + + @Test("OutputFormat createFormatter with pretty flag") + internal func outputFormatCreateFormatterWithPretty() { + let prettyFormatter = OutputFormat.json.createFormatter(pretty: true) + let compactFormatter = OutputFormat.json.createFormatter(pretty: false) + + #expect(prettyFormatter is JSONFormatter) + #expect(compactFormatter is JSONFormatter) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests.swift new file mode 100644 index 00000000..5652c981 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests.swift @@ -0,0 +1,33 @@ +// +// OutputFormatterFactoryTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("OutputFormatterFactory") +internal enum OutputFormatterFactoryTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactoryTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactoryTests.swift deleted file mode 100644 index f3b10a13..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactoryTests.swift +++ /dev/null @@ -1,494 +0,0 @@ -// -// OutputFormatterFactoryTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit -import Testing - -@testable import MistDemo - -@Suite("OutputFormatterFactory Tests") -struct OutputFormatterFactoryTests { - // MARK: - Factory Creation Tests - - @Test("Create JSON formatter") - func createJSONFormatter() { - let formatter = OutputFormatterFactory.formatter(for: .json, pretty: false) - - #expect(formatter is JSONFormatter) - } - - @Test("Create pretty JSON formatter") - func createPrettyJSONFormatter() { - let formatter = OutputFormatterFactory.formatter(for: .json, pretty: true) - - #expect(formatter is JSONFormatter) - } - - @Test("Create CSV formatter") - func createCSVFormatter() { - let formatter = OutputFormatterFactory.formatter(for: .csv, pretty: false) - - #expect(formatter is CSVFormatter) - } - - @Test("Create Table formatter") - func createTableFormatter() { - let formatter = OutputFormatterFactory.formatter(for: .table, pretty: false) - - #expect(formatter is TableFormatter) - } - - @Test("Create YAML formatter") - func createYAMLFormatter() { - let formatter = OutputFormatterFactory.formatter(for: .yaml, pretty: false) - - #expect(formatter is YAMLFormatter) - } - - @Test("Pretty flag ignored for CSV formatter") - func prettyFlagIgnoredForCSV() { - let formatter1 = OutputFormatterFactory.formatter(for: .csv, pretty: false) - let formatter2 = OutputFormatterFactory.formatter(for: .csv, pretty: true) - - #expect(formatter1 is CSVFormatter) - #expect(formatter2 is CSVFormatter) - } - - @Test("Pretty flag ignored for Table formatter") - func prettyFlagIgnoredForTable() { - let formatter1 = OutputFormatterFactory.formatter(for: .table, pretty: false) - let formatter2 = OutputFormatterFactory.formatter(for: .table, pretty: true) - - #expect(formatter1 is TableFormatter) - #expect(formatter2 is TableFormatter) - } - - @Test("Pretty flag ignored for YAML formatter") - func prettyFlagIgnoredForYAML() { - let formatter1 = OutputFormatterFactory.formatter(for: .yaml, pretty: false) - let formatter2 = OutputFormatterFactory.formatter(for: .yaml, pretty: true) - - #expect(formatter1 is YAMLFormatter) - #expect(formatter2 is YAMLFormatter) - } - - // MARK: - Format-Specific Output Tests - - @Test("JSON formatter produces valid JSON") - func jsonFormatterProducesValidJSON() throws { - let formatter = OutputFormatterFactory.formatter(for: .json, pretty: false) - let record = RecordInfo( - recordName: "test-001", - recordType: "Test", - fields: ["field": .string("value")] - ) - - let output = try formatter.format(record) - - // Should be valid JSON - #expect(output.contains("{")) - #expect(output.contains("}")) - #expect(output.contains("\"")) - - // Should be parseable as JSON - let data = Data(output.utf8) - let _ = try JSONSerialization.jsonObject(with: data) - } - - @Test("CSV formatter produces CSV with headers") - func csvFormatterProducesCSVWithHeaders() throws { - let formatter = OutputFormatterFactory.formatter(for: .csv, pretty: false) - let record = RecordInfo( - recordName: "test-001", - recordType: "Test", - fields: ["field": .string("value")] - ) - - let output = try formatter.format(record) - - // Should have CSV header - #expect(output.hasPrefix("Field,Value\n")) - } - - @Test("Table formatter produces human-readable output") - func tableFormatterProducesReadableOutput() throws { - let formatter = OutputFormatterFactory.formatter(for: .table, pretty: false) - let record = RecordInfo( - recordName: "test-001", - recordType: "Test", - fields: ["field": .string("value")] - ) - - let output = try formatter.format(record) - - // Should have human-readable labels - #expect(output.contains("Record Name:")) - #expect(output.contains("Record Type:")) - } - - @Test("YAML formatter produces YAML structure") - func yamlFormatterProducesYAMLStructure() throws { - let formatter = OutputFormatterFactory.formatter(for: .yaml, pretty: false) - let record = RecordInfo( - recordName: "test-001", - recordType: "Test", - fields: ["field": .string("value")] - ) - - let output = try formatter.format(record) - - // Should have YAML structure - #expect(output.contains("recordName:")) - #expect(output.contains("recordType:")) - } - - // MARK: - OutputFormat Enum Tests - - @Test("OutputFormat case count") - func outputFormatCaseCount() { - let allCases = OutputFormat.allCases - - #expect(allCases.count == 4) - #expect(allCases.contains(.json)) - #expect(allCases.contains(.csv)) - #expect(allCases.contains(.table)) - #expect(allCases.contains(.yaml)) - } - - @Test("OutputFormat raw values") - func outputFormatRawValues() { - #expect(OutputFormat.json.rawValue == "json") - #expect(OutputFormat.csv.rawValue == "csv") - #expect(OutputFormat.table.rawValue == "table") - #expect(OutputFormat.yaml.rawValue == "yaml") - } - - @Test("OutputFormat from raw value") - func outputFormatFromRawValue() { - #expect(OutputFormat(rawValue: "json") == .json) - #expect(OutputFormat(rawValue: "csv") == .csv) - #expect(OutputFormat(rawValue: "table") == .table) - #expect(OutputFormat(rawValue: "yaml") == .yaml) - #expect(OutputFormat(rawValue: "invalid") == nil) - } - - @Test("OutputFormat createFormatter method") - func outputFormatCreateFormatter() { - let jsonFormatter = OutputFormat.json.createFormatter(pretty: false) - let csvFormatter = OutputFormat.csv.createFormatter() - let tableFormatter = OutputFormat.table.createFormatter() - let yamlFormatter = OutputFormat.yaml.createFormatter() - - #expect(jsonFormatter is JSONFormatter) - #expect(csvFormatter is CSVFormatter) - #expect(tableFormatter is TableFormatter) - #expect(yamlFormatter is YAMLFormatter) - } - - @Test("OutputFormat createFormatter with pretty flag") - func outputFormatCreateFormatterWithPretty() { - let prettyFormatter = OutputFormat.json.createFormatter(pretty: true) - let compactFormatter = OutputFormat.json.createFormatter(pretty: false) - - #expect(prettyFormatter is JSONFormatter) - #expect(compactFormatter is JSONFormatter) - } - - // MARK: - Integration Tests - - @Test("All formatters can format RecordInfo") - func allFormattersCanFormatRecordInfo() throws { - let record = RecordInfo( - recordName: "integration-001", - recordType: "Integration", - fields: [ - "string": .string("test"), - "number": .int64(42) - ] - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(record) - - #expect(!output.isEmpty) - } - } - - @Test("All formatters can format UserInfo") - func allFormattersCanFormatUserInfo() throws { - let user = UserInfo.test( - userRecordName: "user-001", - firstName: "Test", - lastName: "User", - emailAddress: "test@example.com" - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(user) - - #expect(!output.isEmpty) - } - } - - @Test("Different formatters produce different output") - func differentFormattersProduceDifferentOutput() throws { - let record = RecordInfo( - recordName: "diff-001", - recordType: "Diff", - fields: ["field": .string("value")] - ) - - let jsonOutput = try OutputFormatterFactory.formatter(for: .json, pretty: false) - .format(record) - let csvOutput = try OutputFormatterFactory.formatter(for: .csv, pretty: false) - .format(record) - let tableOutput = try OutputFormatterFactory.formatter(for: .table, pretty: false) - .format(record) - let yamlOutput = try OutputFormatterFactory.formatter(for: .yaml, pretty: false) - .format(record) - - // All outputs should be different - #expect(jsonOutput != csvOutput) - #expect(jsonOutput != tableOutput) - #expect(jsonOutput != yamlOutput) - #expect(csvOutput != tableOutput) - #expect(csvOutput != yamlOutput) - #expect(tableOutput != yamlOutput) - } - - @Test("JSON pretty vs compact produces different output") - func jsonPrettyVsCompactDifferent() throws { - let record = RecordInfo( - recordName: "pretty-001", - recordType: "Pretty", - fields: ["field": .string("value")] - ) - - let prettyOutput = try OutputFormatterFactory.formatter(for: .json, pretty: true) - .format(record) - let compactOutput = try OutputFormatterFactory.formatter(for: .json, pretty: false) - .format(record) - - // Pretty should have more whitespace - #expect(prettyOutput.count > compactOutput.count) - #expect(prettyOutput.contains("\n")) - } - - // MARK: - Formatter Behavior Consistency - - @Test("All formatters handle empty fields") - func allFormattersHandleEmptyFields() throws { - let record = RecordInfo( - recordName: "empty-001", - recordType: "Empty", - fields: [:] - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(record) - - #expect(!output.isEmpty) - #expect(output.contains("empty-001")) - } - } - - @Test("All formatters handle special characters") - func allFormattersHandleSpecialCharacters() throws { - let record = RecordInfo( - recordName: "special-001", - recordType: "Special", - fields: [ - "quotes": .string("He said \"hello\""), - "newlines": .string("Line1\nLine2"), - "commas": .string("a,b,c") - ] - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - - // Should not throw - let output = try formatter.format(record) - #expect(!output.isEmpty) - } - } - - @Test("All formatters handle complex field types") - func allFormattersHandleComplexFieldTypes() throws { - let record = RecordInfo( - recordName: "complex-001", - recordType: "Complex", - fields: [ - "reference": .reference(.init(recordName: "ref-001")), - "location": .location(.init(latitude: 37.7749, longitude: -122.4194)), - "list": .list([.string("item1"), .string("item2")]) - ] - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - - // Should not throw - let output = try formatter.format(record) - #expect(!output.isEmpty) - } - } - - @Test("All formatters handle minimal UserInfo") - func allFormattersHandleMinimalUserInfo() throws { - let user = UserInfo.test(userRecordName: "user-min") - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(user) - - #expect(!output.isEmpty) - #expect(output.contains("user-min")) - } - } - - @Test("Factory produces working formatters for all formats") - func factoryProducesWorkingFormatters() throws { - let testData = RecordInfo( - recordName: "test", - recordType: "Test", - fields: ["key": .string("value")] - ) - - // JSON - let jsonFormatter = OutputFormatterFactory.formatter(for: .json) - let jsonOutput = try jsonFormatter.format(testData) - #expect(jsonOutput.contains("test")) - - // CSV - let csvFormatter = OutputFormatterFactory.formatter(for: .csv) - let csvOutput = try csvFormatter.format(testData) - #expect(csvOutput.contains("Field,Value")) - - // Table - let tableFormatter = OutputFormatterFactory.formatter(for: .table) - let tableOutput = try tableFormatter.format(testData) - #expect(tableOutput.contains("Record Name:")) - - // YAML - let yamlFormatter = OutputFormatterFactory.formatter(for: .yaml) - let yamlOutput = try yamlFormatter.format(testData) - #expect(yamlOutput.contains("recordName:")) - } - - // MARK: - Edge Cases - - @Test("Formatters handle Unicode characters") - func formattersHandleUnicode() throws { - let record = RecordInfo( - recordName: "unicode-001", - recordType: "Unicode", - fields: [ - "emoji": .string("😀🎉✨"), - "chinese": .string("你好世界"), - "arabic": .string("مرحبا"), - "accents": .string("café résumé") - ] - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(record) - - #expect(!output.isEmpty) - } - } - - @Test("Formatters handle very long strings") - func formattersHandleVeryLongStrings() throws { - let longString = String(repeating: "a", count: 10000) - let record = RecordInfo( - recordName: "long-001", - recordType: "Long", - fields: ["long": .string(longString)] - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(record) - - #expect(!output.isEmpty) - #expect(output.count >= longString.count) - } - } - - @Test("Formatters handle many fields") - func formattersHandleManyFields() throws { - var fields: [String: FieldValue] = [:] - for i in 0..<100 { - fields["field\(i)"] = .string("value\(i)") - } - - let record = RecordInfo( - recordName: "many-001", - recordType: "Many", - fields: fields - ) - - for format in OutputFormat.allCases { - let formatter = OutputFormatterFactory.formatter(for: format, pretty: false) - let output = try formatter.format(record) - - #expect(!output.isEmpty) - } - } - - @Test("Default pretty parameter is false") - func defaultPrettyParameterIsFalse() throws { - let record = RecordInfo( - recordName: "default-001", - recordType: "Default", - fields: ["field": .string("value")] - ) - - // Call without pretty parameter (defaults to false) - let defaultFormatter = OutputFormatterFactory.formatter(for: .json) - let defaultOutput = try defaultFormatter.format(record) - - // Call with explicit pretty: false - let explicitFormatter = OutputFormatterFactory.formatter(for: .json, pretty: false) - let explicitOutput = try explicitFormatter.format(record) - - // Both should produce compact output (single line) - let defaultLines = defaultOutput.components(separatedBy: "\n").filter { !$0.isEmpty } - let explicitLines = explicitOutput.components(separatedBy: "\n").filter { !$0.isEmpty } - - #expect(defaultLines.count == 1) - #expect(explicitLines.count == 1) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift new file mode 100644 index 00000000..e11cc272 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift @@ -0,0 +1,132 @@ +// +// TableFormatterTests+EdgeCases+FieldTypes.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension TableFormatterTests.EdgeCases { + @Suite("Edge Cases — Field Types") + internal struct FieldTypes { + @Test("Format empty string values") + internal func formatEmptyStringValues() throws { + let record = RecordInfo( + recordName: "", + recordType: "", + fields: [ + "empty": .string("") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Empty strings should still produce valid table output + #expect(output.contains("Record Name:")) + #expect(output.contains("Record Type:")) + } + + @Test("Format with complex field types") + internal func formatWithComplexFieldTypes() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Complex", + fields: [ + "reference": .reference(.init(recordName: "ref-001")), + "location": .location(.init(latitude: 37.7749, longitude: -122.4194)), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Complex types should be converted to string representation + #expect(output.contains("location:")) + #expect(output.contains("reference:")) + } + + @Test("Table output line structure") + internal func verifyTableStructure() throws { + let record = RecordInfo( + recordName: "verify-001", + recordType: "Verify", + fields: [ + "field1": .string("value1"), + "field2": .string("value2"), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + + // Verify structure + #expect(lines.count >= 4) // Record Name + Record Type + Fields header + at least 2 fields + #expect(lines[0].hasPrefix("Record Name:")) + #expect(lines[1].hasPrefix("Record Type:")) + #expect(lines[2] == "Fields:") + } + + @Test("Format fallback to JSON for unknown type") + internal func formatUnknownType() throws { + struct UnknownType: Encodable { + let data: String + } + + let unknown = UnknownType(data: "test") + let formatter = TableFormatter() + + let output = try formatter.format(unknown) + + // Should fall back to pretty JSON format + #expect(output.contains("data")) + #expect(output.contains("test")) + #expect(output.contains("\n")) // Pretty printed JSON has newlines + } + + @Test("Format RecordInfo with list field") + internal func formatRecordWithListField() throws { + let record = RecordInfo( + recordName: "list-001", + recordType: "List", + fields: [ + "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("tags:")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+Whitespace.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+Whitespace.swift new file mode 100644 index 00000000..6fff7c97 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+Whitespace.swift @@ -0,0 +1,135 @@ +// +// TableFormatterTests+EdgeCases+Whitespace.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension TableFormatterTests.EdgeCases { + @Suite("Edge Cases — Whitespace") + internal struct Whitespace { + @Test("Whitespace trimming verification") + internal func verifyWhitespaceTrimming() throws { + let record = RecordInfo( + recordName: "trim-test", + recordType: "Trim", + fields: [ + "text1": .string(" leading"), + "text2": .string("trailing "), + "text3": .string(" both "), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Values should be trimmed + #expect(output.contains("text1: leading")) + #expect(output.contains("text2: trailing")) + #expect(output.contains("text3: both")) + } + + @Test("Single-line conversion with consecutive whitespace") + internal func formatConsecutiveWhitespace() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Whitespace", + fields: [ + "content": .string("Multiple\n\n\nnewlines and\t\t\ttabs") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Multiple consecutive whitespace chars should each be converted + #expect(output.contains("content: Multiple")) + } + + @Test("Format record with only whitespace values") + internal func formatRecordWithOnlyWhitespace() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Whitespace", + fields: [ + "spaces": .string(" "), + "tabs": .string("\t\t\t"), + "newlines": .string("\n\n\n"), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // All whitespace values should be trimmed to empty + // But field names should still appear + #expect(output.contains("spaces:")) + #expect(output.contains("tabs:")) + #expect(output.contains("newlines:")) + } + + @Test("Format UserInfo with whitespace in email") + internal func formatUserWithWhitespaceInEmail() throws { + let user = UserInfo.test( + userRecordName: "user-005", + emailAddress: "test\n@example.com" + ) + let formatter = TableFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("Email: test @example.com")) + } + + @Test("Readable table format verification") + internal func verifyReadableFormat() throws { + let record = RecordInfo( + recordName: "readable-001", + recordType: "ReadableTest", + fields: [ + "field": .string("value") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Output should be human-readable with proper labels + #expect(output.contains("Record Name:")) + #expect(output.contains("Record Type:")) + #expect(output.contains("Fields:")) + + // Each line should end with a newline + let lines = output.components(separatedBy: "\n") + #expect(lines.count > 1) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases.swift new file mode 100644 index 00000000..1d74bb1d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases.swift @@ -0,0 +1,35 @@ +// +// TableFormatterTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +extension TableFormatterTests { + @Suite("Edge Cases") + internal struct EdgeCases {} +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+RecordInfo.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+RecordInfo.swift new file mode 100644 index 00000000..b7615331 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+RecordInfo.swift @@ -0,0 +1,165 @@ +// +// TableFormatterTests+RecordInfo.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension TableFormatterTests { + @Suite("RecordInfo") + internal struct RecordInfoFormat { + @Test("Format basic RecordInfo with table structure") + internal func formatBasicRecord() throws { + let record = RecordInfo( + recordName: "record-001", + recordType: "TodoItem", + recordChangeTag: "tag123", + fields: [:] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("Record Name: record-001")) + #expect(output.contains("Record Type: TodoItem")) + } + + @Test("Format RecordInfo with string fields") + internal func formatRecordWithStringFields() throws { + let record = RecordInfo( + recordName: "task-001", + recordType: "Task", + fields: [ + "title": .string("Buy groceries"), + "status": .string("pending"), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("Record Name: task-001")) + #expect(output.contains("Record Type: Task")) + #expect(output.contains("Fields:")) + #expect(output.contains("title: Buy groceries")) + #expect(output.contains("status: pending")) + } + + @Test("Format RecordInfo with numeric fields") + internal func formatRecordWithNumericFields() throws { + let record = RecordInfo( + recordName: "item-001", + recordType: "Product", + fields: [ + "price": .double(19.99), + "quantity": .int64(42), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("price:")) + #expect(output.contains("quantity:")) + } + + @Test("Format RecordInfo with sorted field names") + internal func formatRecordWithSortedFields() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Item", + fields: [ + "zebra": .string("last"), + "apple": .string("first"), + "middle": .string("between"), + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + let lines = output.components(separatedBy: "\n") + let fieldLines = lines.filter { $0.contains(":") && $0.hasPrefix(" ") } + + // Extract field names (removing leading spaces and trailing colon+value) + let fieldNames = fieldLines.compactMap { line -> String? in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.components(separatedBy: ":").first + } + + #expect(fieldNames.contains("apple")) + #expect(fieldNames.contains("middle")) + #expect(fieldNames.contains("zebra")) + + // Verify alphabetical order + if let appleIndex = fieldNames.firstIndex(of: "apple"), + let middleIndex = fieldNames.firstIndex(of: "middle"), + let zebraIndex = fieldNames.firstIndex(of: "zebra") + { + #expect(appleIndex < middleIndex) + #expect(middleIndex < zebraIndex) + } + } + + @Test("Format RecordInfo with empty fields") + internal func formatRecordWithEmptyFields() throws { + let record = RecordInfo( + recordName: "empty-001", + recordType: "Empty", + fields: [:] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("Record Name: empty-001")) + #expect(output.contains("Record Type: Empty")) + #expect(!output.contains("Fields:")) + } + + @Test("Format RecordInfo with field indentation") + internal func formatRecordWithFieldIndentation() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Test", + fields: [ + "field1": .string("value1") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Fields should be indented with 2 spaces + #expect(output.contains(" field1: value1")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+SingleLineConversion.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+SingleLineConversion.swift new file mode 100644 index 00000000..8f8b6b04 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+SingleLineConversion.swift @@ -0,0 +1,143 @@ +// +// TableFormatterTests+SingleLineConversion.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension TableFormatterTests { + @Suite("Single-line Conversion") + internal struct SingleLineConversion { + @Test("Format RecordInfo with newline in field value") + internal func formatRecordWithNewlineInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Text", + fields: [ + "content": .string("Line one\nLine two\nLine three") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Newlines should be converted to spaces for single-line display + #expect(output.contains("content: Line one Line two Line three")) + #expect(!output.contains("Line one\nLine two")) + } + + @Test("Format RecordInfo with carriage return in value") + internal func formatRecordWithCarriageReturnInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Text", + fields: [ + "content": .string("Line one\rLine two") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Carriage returns should be converted to spaces + #expect(output.contains("content: Line one Line two")) + } + + @Test("Format RecordInfo with tab in field value") + internal func formatRecordWithTabInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Data", + fields: [ + "content": .string("Column1\tColumn2\tColumn3") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Tabs should be converted to spaces + #expect(output.contains("content: Column1 Column2 Column3")) + } + + @Test("Format RecordInfo with mixed whitespace") + internal func formatRecordWithMixedWhitespace() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Mixed", + fields: [ + "content": .string("Text\n\twith\r\nmixed\twhitespace") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Each whitespace char is converted to a single space (consecutive → multiple spaces) + #expect(output.contains("content: Text")) + } + + @Test("Format RecordInfo with leading and trailing whitespace") + internal func formatRecordWithLeadingTrailingWhitespace() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Trim", + fields: [ + "content": .string(" \n\tvalue with spaces\t\n ") + ] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Leading and trailing whitespace should be trimmed + #expect(output.contains("content: value with spaces")) + #expect(!output.contains("content: ")) + #expect(!output.contains(" value")) + } + + @Test("Format record name with special characters") + internal func formatRecordNameWithSpecialChars() throws { + let record = RecordInfo( + recordName: "record\nwith\nnewlines", + recordType: "Type\twith\ttabs", + fields: [:] + ) + let formatter = TableFormatter() + + let output = try formatter.format(record) + + // Record name and type should have whitespace converted to spaces + #expect(output.contains("Record Name: record with newlines")) + #expect(output.contains("Record Type: Type with tabs")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+UserInfo.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+UserInfo.swift new file mode 100644 index 00000000..d67e68de --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+UserInfo.swift @@ -0,0 +1,118 @@ +// +// TableFormatterTests+UserInfo.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension TableFormatterTests { + @Suite("UserInfo") + internal struct UserInfoFormat { + @Test("Format basic UserInfo") + internal func formatBasicUser() throws { + let user = UserInfo.test( + userRecordName: "user-001", + firstName: "John", + lastName: "Doe", + emailAddress: "john.doe@example.com" + ) + let formatter = TableFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("User Record Name: user-001")) + #expect(output.contains("First Name: John")) + #expect(output.contains("Last Name: Doe")) + #expect(output.contains("Email: john.doe@example.com")) + } + + @Test("Format UserInfo with minimal fields") + internal func formatUserWithMinimalFields() throws { + let user = UserInfo.test(userRecordName: "user-min") + let formatter = TableFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("User Record Name: user-min")) + #expect(!output.contains("First Name:")) + #expect(!output.contains("Last Name:")) + #expect(!output.contains("Email:")) + } + + @Test("Format UserInfo with partial fields") + internal func formatUserWithPartialFields() throws { + let user = UserInfo.test( + userRecordName: "user-002", + firstName: "Jane", + emailAddress: "jane@example.com" + ) + let formatter = TableFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("User Record Name: user-002")) + #expect(output.contains("First Name: Jane")) + #expect(!output.contains("Last Name:")) + #expect(output.contains("Email: jane@example.com")) + } + + @Test("Format UserInfo with newlines in fields") + internal func formatUserWithNewlinesInFields() throws { + let user = UserInfo.test( + userRecordName: "user-003", + firstName: "John\nJacob", + lastName: "Smith\nJones" + ) + let formatter = TableFormatter() + + let output = try formatter.format(user) + + // Newlines should be converted to spaces + #expect(output.contains("First Name: John Jacob")) + #expect(output.contains("Last Name: Smith Jones")) + } + + @Test("Format UserInfo with special characters") + internal func formatUserWithSpecialChars() throws { + let user = UserInfo.test( + userRecordName: "user-004", + firstName: "O'Brien", + lastName: "Müller" + ) + let formatter = TableFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("First Name: O'Brien")) + #expect(output.contains("Last Name: Müller")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests.swift new file mode 100644 index 00000000..5aedcfc9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests.swift @@ -0,0 +1,33 @@ +// +// TableFormatterTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("TableFormatter") +internal enum TableFormatterTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatterTests.swift deleted file mode 100644 index e7f019dc..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatterTests.swift +++ /dev/null @@ -1,542 +0,0 @@ -// -// TableFormatterTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit -import Testing - -@testable import MistDemo - -@Suite("TableFormatter Tests") -struct TableFormatterTests { - // MARK: - RecordInfo Tests - - @Test("Format basic RecordInfo with table structure") - func formatBasicRecord() throws { - let record = RecordInfo( - recordName: "record-001", - recordType: "TodoItem", - recordChangeTag: "tag123", - fields: [:] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("Record Name: record-001")) - #expect(output.contains("Record Type: TodoItem")) - } - - @Test("Format RecordInfo with string fields") - func formatRecordWithStringFields() throws { - let record = RecordInfo( - recordName: "task-001", - recordType: "Task", - fields: [ - "title": .string("Buy groceries"), - "status": .string("pending") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("Record Name: task-001")) - #expect(output.contains("Record Type: Task")) - #expect(output.contains("Fields:")) - #expect(output.contains("title: Buy groceries")) - #expect(output.contains("status: pending")) - } - - @Test("Format RecordInfo with numeric fields") - func formatRecordWithNumericFields() throws { - let record = RecordInfo( - recordName: "item-001", - recordType: "Product", - fields: [ - "price": .double(19.99), - "quantity": .int64(42) - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("price:")) - #expect(output.contains("quantity:")) - } - - @Test("Format RecordInfo with sorted field names") - func formatRecordWithSortedFields() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Item", - fields: [ - "zebra": .string("last"), - "apple": .string("first"), - "middle": .string("between") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - let lines = output.components(separatedBy: "\n") - let fieldLines = lines.filter { $0.contains(":") && $0.hasPrefix(" ") } - - // Extract field names (removing leading spaces and trailing colon+value) - let fieldNames = fieldLines.compactMap { line -> String? in - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed.components(separatedBy: ":").first - } - - #expect(fieldNames.contains("apple")) - #expect(fieldNames.contains("middle")) - #expect(fieldNames.contains("zebra")) - - // Verify alphabetical order - if let appleIndex = fieldNames.firstIndex(of: "apple"), - let middleIndex = fieldNames.firstIndex(of: "middle"), - let zebraIndex = fieldNames.firstIndex(of: "zebra") { - #expect(appleIndex < middleIndex) - #expect(middleIndex < zebraIndex) - } - } - - @Test("Format RecordInfo with empty fields") - func formatRecordWithEmptyFields() throws { - let record = RecordInfo( - recordName: "empty-001", - recordType: "Empty", - fields: [:] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("Record Name: empty-001")) - #expect(output.contains("Record Type: Empty")) - #expect(!output.contains("Fields:")) - } - - @Test("Format RecordInfo with field indentation") - func formatRecordWithFieldIndentation() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Test", - fields: [ - "field1": .string("value1") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Fields should be indented with 2 spaces - #expect(output.contains(" field1: value1")) - } - - // MARK: - Single-line Conversion Tests - - @Test("Format RecordInfo with newline in field value") - func formatRecordWithNewlineInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Text", - fields: [ - "content": .string("Line one\nLine two\nLine three") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Newlines should be converted to spaces for single-line display - #expect(output.contains("content: Line one Line two Line three")) - #expect(!output.contains("Line one\nLine two")) - } - - @Test("Format RecordInfo with carriage return in value") - func formatRecordWithCarriageReturnInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Text", - fields: [ - "content": .string("Line one\rLine two") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Carriage returns should be converted to spaces - #expect(output.contains("content: Line one Line two")) - } - - @Test("Format RecordInfo with tab in field value") - func formatRecordWithTabInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Data", - fields: [ - "content": .string("Column1\tColumn2\tColumn3") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Tabs should be converted to spaces - #expect(output.contains("content: Column1 Column2 Column3")) - } - - @Test("Format RecordInfo with mixed whitespace") - func formatRecordWithMixedWhitespace() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Mixed", - fields: [ - "content": .string("Text\n\twith\r\nmixed\twhitespace") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Each whitespace char is converted to a single space (consecutive → multiple spaces) - #expect(output.contains("content: Text")) - } - - @Test("Format RecordInfo with leading and trailing whitespace") - func formatRecordWithLeadingTrailingWhitespace() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Trim", - fields: [ - "content": .string(" \n\tvalue with spaces\t\n ") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Leading and trailing whitespace should be trimmed - #expect(output.contains("content: value with spaces")) - #expect(!output.contains("content: ")) - #expect(!output.contains(" value")) - } - - @Test("Format record name with special characters") - func formatRecordNameWithSpecialChars() throws { - let record = RecordInfo( - recordName: "record\nwith\nnewlines", - recordType: "Type\twith\ttabs", - fields: [:] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Record name and type should have whitespace converted to spaces - #expect(output.contains("Record Name: record with newlines")) - #expect(output.contains("Record Type: Type with tabs")) - } - - // MARK: - UserInfo Tests - - @Test("Format basic UserInfo") - func formatBasicUser() throws { - let user = UserInfo.test( - userRecordName: "user-001", - firstName: "John", - lastName: "Doe", - emailAddress: "john.doe@example.com" - ) - let formatter = TableFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("User Record Name: user-001")) - #expect(output.contains("First Name: John")) - #expect(output.contains("Last Name: Doe")) - #expect(output.contains("Email: john.doe@example.com")) - } - - @Test("Format UserInfo with minimal fields") - func formatUserWithMinimalFields() throws { - let user = UserInfo.test(userRecordName: "user-min") - let formatter = TableFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("User Record Name: user-min")) - #expect(!output.contains("First Name:")) - #expect(!output.contains("Last Name:")) - #expect(!output.contains("Email:")) - } - - @Test("Format UserInfo with partial fields") - func formatUserWithPartialFields() throws { - let user = UserInfo.test( - userRecordName: "user-002", - firstName: "Jane", - emailAddress: "jane@example.com" - ) - let formatter = TableFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("User Record Name: user-002")) - #expect(output.contains("First Name: Jane")) - #expect(!output.contains("Last Name:")) - #expect(output.contains("Email: jane@example.com")) - } - - @Test("Format UserInfo with newlines in fields") - func formatUserWithNewlinesInFields() throws { - let user = UserInfo.test( - userRecordName: "user-003", - firstName: "John\nJacob", - lastName: "Smith\nJones" - ) - let formatter = TableFormatter() - - let output = try formatter.format(user) - - // Newlines should be converted to spaces - #expect(output.contains("First Name: John Jacob")) - #expect(output.contains("Last Name: Smith Jones")) - } - - @Test("Format UserInfo with special characters") - func formatUserWithSpecialChars() throws { - let user = UserInfo.test( - userRecordName: "user-004", - firstName: "O'Brien", - lastName: "Müller" - ) - let formatter = TableFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("First Name: O'Brien")) - #expect(output.contains("Last Name: Müller")) - } - - // MARK: - Edge Cases - - @Test("Format empty string values") - func formatEmptyStringValues() throws { - let record = RecordInfo( - recordName: "", - recordType: "", - fields: [ - "empty": .string("") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Empty strings should still produce valid table output - #expect(output.contains("Record Name:")) - #expect(output.contains("Record Type:")) - } - - @Test("Format with complex field types") - func formatWithComplexFieldTypes() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Complex", - fields: [ - "reference": .reference(.init(recordName: "ref-001")), - "location": .location(.init(latitude: 37.7749, longitude: -122.4194)) - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Complex types should be converted to string representation - #expect(output.contains("location:")) - #expect(output.contains("reference:")) - } - - @Test("Table output line structure") - func verifyTableStructure() throws { - let record = RecordInfo( - recordName: "verify-001", - recordType: "Verify", - fields: [ - "field1": .string("value1"), - "field2": .string("value2") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } - - // Verify structure - #expect(lines.count >= 4) // Record Name + Record Type + Fields header + at least 2 fields - #expect(lines[0].hasPrefix("Record Name:")) - #expect(lines[1].hasPrefix("Record Type:")) - #expect(lines[2] == "Fields:") - } - - @Test("Format fallback to JSON for unknown type") - func formatUnknownType() throws { - struct UnknownType: Encodable { - let data: String - } - - let unknown = UnknownType(data: "test") - let formatter = TableFormatter() - - let output = try formatter.format(unknown) - - // Should fall back to pretty JSON format - #expect(output.contains("data")) - #expect(output.contains("test")) - #expect(output.contains("\n")) // Pretty printed JSON has newlines - } - - @Test("Format RecordInfo with list field") - func formatRecordWithListField() throws { - let record = RecordInfo( - recordName: "list-001", - recordType: "List", - fields: [ - "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("tags:")) - } - - @Test("Whitespace trimming verification") - func verifyWhitespaceTrimming() throws { - let record = RecordInfo( - recordName: "trim-test", - recordType: "Trim", - fields: [ - "text1": .string(" leading"), - "text2": .string("trailing "), - "text3": .string(" both ") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Values should be trimmed - #expect(output.contains("text1: leading")) - #expect(output.contains("text2: trailing")) - #expect(output.contains("text3: both")) - } - - @Test("Single-line conversion with consecutive whitespace") - func formatConsecutiveWhitespace() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Whitespace", - fields: [ - "content": .string("Multiple\n\n\nnewlines and\t\t\ttabs") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Multiple consecutive whitespace chars should each be converted - #expect(output.contains("content: Multiple")) - } - - @Test("Format record with only whitespace values") - func formatRecordWithOnlyWhitespace() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Whitespace", - fields: [ - "spaces": .string(" "), - "tabs": .string("\t\t\t"), - "newlines": .string("\n\n\n") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // All whitespace values should be trimmed to empty - // But field names should still appear - #expect(output.contains("spaces:")) - #expect(output.contains("tabs:")) - #expect(output.contains("newlines:")) - } - - @Test("Format UserInfo with whitespace in email") - func formatUserWithWhitespaceInEmail() throws { - let user = UserInfo.test( - userRecordName: "user-005", - emailAddress: "test\n@example.com" - ) - let formatter = TableFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("Email: test @example.com")) - } - - @Test("Readable table format verification") - func verifyReadableFormat() throws { - let record = RecordInfo( - recordName: "readable-001", - recordType: "ReadableTest", - fields: [ - "field": .string("value") - ] - ) - let formatter = TableFormatter() - - let output = try formatter.format(record) - - // Output should be human-readable with proper labels - #expect(output.contains("Record Name:")) - #expect(output.contains("Record Type:")) - #expect(output.contains("Fields:")) - - // Each line should end with a newline - let lines = output.components(separatedBy: "\n") - #expect(lines.count > 1) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift new file mode 100644 index 00000000..6ff6d81c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift @@ -0,0 +1,177 @@ +// +// YAMLFormatterTests+EdgeCases.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension YAMLFormatterTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("Format record name with YAML keywords") + internal func formatRecordNameWithYAMLKeywords() throws { + let record = RecordInfo( + recordName: "true", + recordType: "yes", + fields: [:] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // YAML keywords should be quoted + #expect(output.contains("recordName: \"true\"")) + #expect(output.contains("recordType: \"yes\"")) + } + + @Test("Format with complex field types") + internal func formatWithComplexFieldTypes() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Complex", + fields: [ + "reference": .reference(.init(recordName: "ref-001")), + "location": .location(.init(latitude: 37.7749, longitude: -122.4194)), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Complex types should be converted to string representation + #expect(output.contains(" location:")) + #expect(output.contains(" reference:")) + } + + @Test("YAML structure verification") + internal func verifyYAMLStructure() throws { + let record = RecordInfo( + recordName: "verify-001", + recordType: "Verify", + fields: [ + "field1": .string("value1"), + "field2": .string("value2"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + + // Verify YAML structure + #expect(lines[0].hasPrefix("recordName:")) + #expect(lines[1].hasPrefix("recordType:")) + #expect(lines[2] == "fields:") + #expect(lines[3].hasPrefix(" ")) // First field should be indented + } + + @Test("Format fallback to JSON for unknown type") + internal func formatUnknownType() throws { + struct UnknownType: Encodable { + let data: String + } + + let unknown = UnknownType(data: "test") + let formatter = YAMLFormatter() + + let output = try formatter.format(unknown) + + // Should fall back to pretty JSON format + #expect(output.contains("data")) + #expect(output.contains("test")) + } + + @Test("Format RecordInfo with list field") + internal func formatRecordWithListField() throws { + let record = RecordInfo( + recordName: "list-001", + recordType: "List", + fields: [ + "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + #expect(output.contains(" tags:")) + } + + @Test("Format simple value requiring no escaping") + internal func formatSimpleValue() throws { + let record = RecordInfo( + recordName: "simple-001", + recordType: "Simple", + fields: [ + "title": .string("SimpleTitle"), + "status": .string("active"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Simple values should not be quoted + #expect(output.contains(" title: SimpleTitle")) + #expect(output.contains(" status: active")) + #expect(!output.contains("\"SimpleTitle\"")) + #expect(!output.contains("\"active\"")) + } + + @Test("Format RecordInfo with case variations of YAML keywords") + internal func formatRecordWithKeywordCaseVariations() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Keywords", + fields: [ + "field1": .string("Yes"), + "field2": .string("No"), + "field3": .string("True"), + "field4": .string("False"), + "field5": .string("ON"), + "field6": .string("OFF"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // All case variations of YAML keywords should be quoted + #expect(output.contains("\"Yes\"")) + #expect(output.contains("\"No\"")) + #expect(output.contains("\"True\"")) + #expect(output.contains("\"False\"")) + #expect(output.contains("\"ON\"")) + #expect(output.contains("\"OFF\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+MultilineString.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+MultilineString.swift new file mode 100644 index 00000000..b90c1dc9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+MultilineString.swift @@ -0,0 +1,76 @@ +// +// YAMLFormatterTests+MultilineString.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension YAMLFormatterTests { + @Suite("Multiline String") + internal struct MultilineString { + @Test("Format RecordInfo with multiline block scalar") + internal func formatRecordWithMultilineBlockScalar() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Multiline", + fields: [ + "description": .string("First line\nSecond line\nThird line") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Should use literal block scalar + #expect(output.contains(" description: |")) + #expect(output.contains(" First line")) + #expect(output.contains(" Second line")) + #expect(output.contains(" Third line")) + } + + @Test("Format RecordInfo with multiline and empty lines") + internal func formatRecordWithMultilineAndEmptyLines() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Multiline", + fields: [ + "text": .string("Line 1\n\nLine 3") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Should preserve empty lines in block scalar + #expect(output.contains(" text: |")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+RecordInfo.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+RecordInfo.swift new file mode 100644 index 00000000..90a9fce2 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+RecordInfo.swift @@ -0,0 +1,166 @@ +// +// YAMLFormatterTests+RecordInfo.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension YAMLFormatterTests { + @Suite("RecordInfo") + internal struct RecordInfoFormat { + @Test("Format basic RecordInfo with YAML structure") + internal func formatBasicRecord() throws { + let record = RecordInfo( + recordName: "record-001", + recordType: "TodoItem", + recordChangeTag: "tag123", + fields: [:] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("recordName: record-001")) + #expect(output.contains("recordType: TodoItem")) + } + + @Test("Format RecordInfo with string fields") + internal func formatRecordWithStringFields() throws { + let record = RecordInfo( + recordName: "task-001", + recordType: "Task", + fields: [ + "title": .string("Buy groceries"), + "status": .string("pending"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("recordName: task-001")) + #expect(output.contains("recordType: Task")) + #expect(output.contains("fields:")) + #expect(output.contains(" title: Buy groceries")) + #expect(output.contains(" status: pending")) + } + + @Test("Format RecordInfo with numeric fields") + internal func formatRecordWithNumericFields() throws { + let record = RecordInfo( + recordName: "item-001", + recordType: "Product", + fields: [ + "price": .double(19.99), + "quantity": .int64(42), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + #expect(output.contains(" price:")) + #expect(output.contains(" quantity:")) + } + + @Test("Format RecordInfo with sorted field names") + internal func formatRecordWithSortedFields() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Item", + fields: [ + "zebra": .string("last"), + "apple": .string("first"), + "middle": .string("between"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + let lines = output.components(separatedBy: "\n") + let fieldLines = lines.filter { $0.hasPrefix(" ") && $0.contains(":") } + + // Extract field names + let fieldNames = fieldLines.compactMap { line -> String? in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.components(separatedBy: ":").first + } + + #expect(fieldNames.contains("apple")) + #expect(fieldNames.contains("middle")) + #expect(fieldNames.contains("zebra")) + + // Verify alphabetical order + if let appleIndex = fieldNames.firstIndex(of: "apple"), + let middleIndex = fieldNames.firstIndex(of: "middle"), + let zebraIndex = fieldNames.firstIndex(of: "zebra") + { + #expect(appleIndex < middleIndex) + #expect(middleIndex < zebraIndex) + } + } + + @Test("Format RecordInfo with empty fields") + internal func formatRecordWithEmptyFields() throws { + let record = RecordInfo( + recordName: "empty-001", + recordType: "Empty", + fields: [:] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + #expect(output.contains("recordName: empty-001")) + #expect(output.contains("recordType: Empty")) + #expect(!output.contains("fields:")) + } + + @Test("Format RecordInfo with field indentation") + internal func formatRecordWithFieldIndentation() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Test", + fields: [ + "field1": .string("value1") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Fields should be indented with 2 spaces + #expect(output.contains("fields:\n")) + #expect(output.contains(" field1: value1")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+UserInfo.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+UserInfo.swift new file mode 100644 index 00000000..450ad301 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+UserInfo.swift @@ -0,0 +1,115 @@ +// +// YAMLFormatterTests+UserInfo.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension YAMLFormatterTests { + @Suite("UserInfo") + internal struct UserInfoFormat { + @Test("Format basic UserInfo") + internal func formatBasicUser() throws { + let user = UserInfo.test( + userRecordName: "user-001", + firstName: "John", + lastName: "Doe", + emailAddress: "john.doe@example.com" + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("userRecordName: user-001")) + #expect(output.contains("firstName: John")) + #expect(output.contains("lastName: Doe")) + #expect(output.contains("emailAddress: john.doe@example.com")) + } + + @Test("Format UserInfo with minimal fields") + internal func formatUserWithMinimalFields() throws { + let user = UserInfo.test(userRecordName: "user-min") + let formatter = YAMLFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("userRecordName: user-min")) + #expect(!output.contains("firstName:")) + #expect(!output.contains("lastName:")) + #expect(!output.contains("emailAddress:")) + } + + @Test("Format UserInfo with partial fields") + internal func formatUserWithPartialFields() throws { + let user = UserInfo.test( + userRecordName: "user-002", + firstName: "Jane", + emailAddress: "jane@example.com" + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("userRecordName: user-002")) + #expect(output.contains("firstName: Jane")) + #expect(!output.contains("lastName:")) + #expect(output.contains("emailAddress: jane@example.com")) + } + + @Test("Format UserInfo with special characters in name") + internal func formatUserWithSpecialCharsInName() throws { + let user = UserInfo.test( + userRecordName: "user-003", + firstName: "O'Brien", + lastName: "Smith: Jr." + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("firstName: O'Brien")) + #expect(output.contains("\"Smith: Jr.\"")) // Colon should cause quoting + } + + @Test("Format UserInfo with email containing special chars") + internal func formatUserWithSpecialCharsInEmail() throws { + let user = UserInfo.test( + userRecordName: "user-004", + emailAddress: "test+tag@example.com" + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(user) + + #expect(output.contains("emailAddress: test+tag@example.com")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping+ReservedStrings.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping+ReservedStrings.swift new file mode 100644 index 00000000..49b8aae5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping+ReservedStrings.swift @@ -0,0 +1,151 @@ +// +// YAMLFormatterTests+YAMLEscaping+ReservedStrings.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension YAMLFormatterTests.YAMLEscaping { + @Suite("YAML Escaping — Reserved Strings") + internal struct ReservedStrings { + @Test("Format RecordInfo with YAML boolean keywords") + internal func formatRecordWithBooleanKeywords() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Keywords", + fields: [ + "yes_field": .string("yes"), + "no_field": .string("no"), + "true_field": .string("true"), + "false_field": .string("false"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // YAML boolean keywords should be quoted + #expect(output.contains("\"yes\"")) + #expect(output.contains("\"no\"")) + #expect(output.contains("\"true\"")) + #expect(output.contains("\"false\"")) + } + + @Test("Format RecordInfo with numeric string") + internal func formatRecordWithNumericString() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Numeric", + fields: [ + "code": .string("12345"), + "decimal": .string("3.14"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Numeric strings should be quoted to preserve as strings + #expect(output.contains("\"12345\"")) + #expect(output.contains("\"3.14\"")) + } + + @Test("Format RecordInfo with empty string value") + internal func formatRecordWithEmptyStringValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Empty", + fields: [ + "empty": .string("") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Empty string should be quoted + #expect(output.contains(" empty: \"\"")) + } + + @Test("Format RecordInfo with leading whitespace") + internal func formatRecordWithLeadingWhitespace() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Whitespace", + fields: [ + "text": .string(" leading spaces") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Leading whitespace should cause quoting + #expect(output.contains("\" leading spaces\"")) + } + + @Test("Format RecordInfo with trailing whitespace") + internal func formatRecordWithTrailingWhitespace() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Whitespace", + fields: [ + "text": .string("trailing spaces ") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Trailing whitespace should cause quoting + #expect(output.contains("\"trailing spaces \"")) + } + + @Test("Format RecordInfo with null keyword") + internal func formatRecordWithNullKeyword() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Null", + fields: [ + "value": .string("null"), + "tilde": .string("~"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // YAML null keywords should be quoted + #expect(output.contains("\"null\"")) + #expect(output.contains("\"~\"")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping+SpecialChars.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping+SpecialChars.swift new file mode 100644 index 00000000..b53531e4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping+SpecialChars.swift @@ -0,0 +1,181 @@ +// +// YAMLFormatterTests+YAMLEscaping+SpecialChars.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension YAMLFormatterTests.YAMLEscaping { + @Suite("YAML Escaping — Special Characters") + internal struct SpecialChars { + @Test("Format RecordInfo with colon in value") + internal func formatRecordWithColonInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Colon", + fields: [ + "content": .string("Key: Value") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Value with colon should be quoted + #expect(output.contains(" content: \"Key: Value\"")) + } + + @Test("Format RecordInfo with hash in value") + internal func formatRecordWithHashInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Hash", + fields: [ + "tag": .string("#important") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Value starting with # should be quoted + #expect(output.contains(" tag: \"#important\"")) + } + + @Test("Format RecordInfo with quotes in value") + internal func formatRecordWithQuotesInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Quote", + fields: [ + "text": .string("He said \"hello\"") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Quotes should be escaped with backslash + #expect(output.contains("\\\"")) + } + + @Test("Format RecordInfo with newline in value") + internal func formatRecordWithNewlineInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Text", + fields: [ + "content": .string("Line one\nLine two") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Multiline string should use block scalar + #expect(output.contains(" content: |")) + } + + @Test("Format RecordInfo with backslash in value") + internal func formatRecordWithBackslashInValue() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Path", + fields: [ + "path": .string("C:\\Users\\test") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Backslashes should be escaped + #expect(output.contains("\\\\")) + } + + @Test("Format RecordInfo with special YAML characters") + internal func formatRecordWithSpecialYAMLChars() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Special", + fields: [ + "brackets": .string("[array]"), + "braces": .string("{object}"), + "ampersand": .string("&reference"), + "asterisk": .string("*alias"), + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Special YAML characters should be quoted + #expect(output.contains("\"[array]\"")) + #expect(output.contains("\"{object}\"")) + #expect(output.contains("\"&reference\"")) + #expect(output.contains("\"*alias\"")) + } + + @Test("Format RecordInfo with tab character") + internal func formatRecordWithTabCharacter() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "Tab", + fields: [ + "content": .string("Column1\tColumn2") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Tab should be escaped + #expect(output.contains("\\t")) + } + + @Test("Format RecordInfo with carriage return") + internal func formatRecordWithCarriageReturn() throws { + let record = RecordInfo( + recordName: "rec-001", + recordType: "CR", + fields: [ + "content": .string("Line1\rLine2") + ] + ) + let formatter = YAMLFormatter() + + let output = try formatter.format(record) + + // Carriage return should be escaped + #expect(output.contains("\\r")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping.swift new file mode 100644 index 00000000..5df6d6e7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+YAMLEscaping.swift @@ -0,0 +1,35 @@ +// +// YAMLFormatterTests+YAMLEscaping.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +extension YAMLFormatterTests { + @Suite("YAML Escaping") + internal struct YAMLEscaping {} +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests.swift new file mode 100644 index 00000000..bd33b100 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests.swift @@ -0,0 +1,33 @@ +// +// YAMLFormatterTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("YAMLFormatter") +internal enum YAMLFormatterTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatterTests.swift deleted file mode 100644 index a5e59504..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatterTests.swift +++ /dev/null @@ -1,678 +0,0 @@ -// -// YAMLFormatterTests.swift -// MistDemoTests -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit -import Testing - -@testable import MistDemo - -@Suite("YAMLFormatter Tests") -struct YAMLFormatterTests { - // MARK: - RecordInfo Tests - - @Test("Format basic RecordInfo with YAML structure") - func formatBasicRecord() throws { - let record = RecordInfo( - recordName: "record-001", - recordType: "TodoItem", - recordChangeTag: "tag123", - fields: [:] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("recordName: record-001")) - #expect(output.contains("recordType: TodoItem")) - } - - @Test("Format RecordInfo with string fields") - func formatRecordWithStringFields() throws { - let record = RecordInfo( - recordName: "task-001", - recordType: "Task", - fields: [ - "title": .string("Buy groceries"), - "status": .string("pending") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("recordName: task-001")) - #expect(output.contains("recordType: Task")) - #expect(output.contains("fields:")) - #expect(output.contains(" title: Buy groceries")) - #expect(output.contains(" status: pending")) - } - - @Test("Format RecordInfo with numeric fields") - func formatRecordWithNumericFields() throws { - let record = RecordInfo( - recordName: "item-001", - recordType: "Product", - fields: [ - "price": .double(19.99), - "quantity": .int64(42) - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - #expect(output.contains(" price:")) - #expect(output.contains(" quantity:")) - } - - @Test("Format RecordInfo with sorted field names") - func formatRecordWithSortedFields() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Item", - fields: [ - "zebra": .string("last"), - "apple": .string("first"), - "middle": .string("between") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - let lines = output.components(separatedBy: "\n") - let fieldLines = lines.filter { $0.hasPrefix(" ") && $0.contains(":") } - - // Extract field names - let fieldNames = fieldLines.compactMap { line -> String? in - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed.components(separatedBy: ":").first - } - - #expect(fieldNames.contains("apple")) - #expect(fieldNames.contains("middle")) - #expect(fieldNames.contains("zebra")) - - // Verify alphabetical order - if let appleIndex = fieldNames.firstIndex(of: "apple"), - let middleIndex = fieldNames.firstIndex(of: "middle"), - let zebraIndex = fieldNames.firstIndex(of: "zebra") { - #expect(appleIndex < middleIndex) - #expect(middleIndex < zebraIndex) - } - } - - @Test("Format RecordInfo with empty fields") - func formatRecordWithEmptyFields() throws { - let record = RecordInfo( - recordName: "empty-001", - recordType: "Empty", - fields: [:] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - #expect(output.contains("recordName: empty-001")) - #expect(output.contains("recordType: Empty")) - #expect(!output.contains("fields:")) - } - - @Test("Format RecordInfo with field indentation") - func formatRecordWithFieldIndentation() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Test", - fields: [ - "field1": .string("value1") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Fields should be indented with 2 spaces - #expect(output.contains("fields:\n")) - #expect(output.contains(" field1: value1")) - } - - // MARK: - YAML Escaping Tests - - @Test("Format RecordInfo with colon in value") - func formatRecordWithColonInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Colon", - fields: [ - "content": .string("Key: Value") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Value with colon should be quoted - #expect(output.contains(" content: \"Key: Value\"")) - } - - @Test("Format RecordInfo with hash in value") - func formatRecordWithHashInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Hash", - fields: [ - "tag": .string("#important") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Value starting with # should be quoted - #expect(output.contains(" tag: \"#important\"")) - } - - @Test("Format RecordInfo with quotes in value") - func formatRecordWithQuotesInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Quote", - fields: [ - "text": .string("He said \"hello\"") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Quotes should be escaped with backslash - #expect(output.contains("\\\"")) - } - - @Test("Format RecordInfo with newline in value") - func formatRecordWithNewlineInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Text", - fields: [ - "content": .string("Line one\nLine two") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Multiline string should use block scalar - #expect(output.contains(" content: |")) - } - - @Test("Format RecordInfo with backslash in value") - func formatRecordWithBackslashInValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Path", - fields: [ - "path": .string("C:\\Users\\test") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Backslashes should be escaped - #expect(output.contains("\\\\")) - } - - @Test("Format RecordInfo with YAML boolean keywords") - func formatRecordWithBooleanKeywords() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Keywords", - fields: [ - "yes_field": .string("yes"), - "no_field": .string("no"), - "true_field": .string("true"), - "false_field": .string("false") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // YAML boolean keywords should be quoted - #expect(output.contains("\"yes\"")) - #expect(output.contains("\"no\"")) - #expect(output.contains("\"true\"")) - #expect(output.contains("\"false\"")) - } - - @Test("Format RecordInfo with numeric string") - func formatRecordWithNumericString() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Numeric", - fields: [ - "code": .string("12345"), - "decimal": .string("3.14") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Numeric strings should be quoted to preserve as strings - #expect(output.contains("\"12345\"")) - #expect(output.contains("\"3.14\"")) - } - - @Test("Format RecordInfo with empty string value") - func formatRecordWithEmptyStringValue() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Empty", - fields: [ - "empty": .string("") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Empty string should be quoted - #expect(output.contains(" empty: \"\"")) - } - - @Test("Format RecordInfo with leading whitespace") - func formatRecordWithLeadingWhitespace() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Whitespace", - fields: [ - "text": .string(" leading spaces") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Leading whitespace should cause quoting - #expect(output.contains("\" leading spaces\"")) - } - - @Test("Format RecordInfo with trailing whitespace") - func formatRecordWithTrailingWhitespace() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Whitespace", - fields: [ - "text": .string("trailing spaces ") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Trailing whitespace should cause quoting - #expect(output.contains("\"trailing spaces \"")) - } - - @Test("Format RecordInfo with special YAML characters") - func formatRecordWithSpecialYAMLChars() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Special", - fields: [ - "brackets": .string("[array]"), - "braces": .string("{object}"), - "ampersand": .string("&reference"), - "asterisk": .string("*alias") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Special YAML characters should be quoted - #expect(output.contains("\"[array]\"")) - #expect(output.contains("\"{object}\"")) - #expect(output.contains("\"&reference\"")) - #expect(output.contains("\"*alias\"")) - } - - @Test("Format RecordInfo with tab character") - func formatRecordWithTabCharacter() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Tab", - fields: [ - "content": .string("Column1\tColumn2") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Tab should be escaped - #expect(output.contains("\\t")) - } - - @Test("Format RecordInfo with carriage return") - func formatRecordWithCarriageReturn() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "CR", - fields: [ - "content": .string("Line1\rLine2") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Carriage return should be escaped - #expect(output.contains("\\r")) - } - - @Test("Format RecordInfo with null keyword") - func formatRecordWithNullKeyword() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Null", - fields: [ - "value": .string("null"), - "tilde": .string("~") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // YAML null keywords should be quoted - #expect(output.contains("\"null\"")) - #expect(output.contains("\"~\"")) - } - - // MARK: - Multiline String Tests - - @Test("Format RecordInfo with multiline block scalar") - func formatRecordWithMultilineBlockScalar() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Multiline", - fields: [ - "description": .string("First line\nSecond line\nThird line") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Should use literal block scalar - #expect(output.contains(" description: |")) - #expect(output.contains(" First line")) - #expect(output.contains(" Second line")) - #expect(output.contains(" Third line")) - } - - @Test("Format RecordInfo with multiline and empty lines") - func formatRecordWithMultilineAndEmptyLines() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Multiline", - fields: [ - "text": .string("Line 1\n\nLine 3") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Should preserve empty lines in block scalar - #expect(output.contains(" text: |")) - } - - // MARK: - UserInfo Tests - - @Test("Format basic UserInfo") - func formatBasicUser() throws { - let user = UserInfo.test( - userRecordName: "user-001", - firstName: "John", - lastName: "Doe", - emailAddress: "john.doe@example.com" - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("userRecordName: user-001")) - #expect(output.contains("firstName: John")) - #expect(output.contains("lastName: Doe")) - #expect(output.contains("emailAddress: john.doe@example.com")) - } - - @Test("Format UserInfo with minimal fields") - func formatUserWithMinimalFields() throws { - let user = UserInfo.test(userRecordName: "user-min") - let formatter = YAMLFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("userRecordName: user-min")) - #expect(!output.contains("firstName:")) - #expect(!output.contains("lastName:")) - #expect(!output.contains("emailAddress:")) - } - - @Test("Format UserInfo with partial fields") - func formatUserWithPartialFields() throws { - let user = UserInfo.test( - userRecordName: "user-002", - firstName: "Jane", - emailAddress: "jane@example.com" - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("userRecordName: user-002")) - #expect(output.contains("firstName: Jane")) - #expect(!output.contains("lastName:")) - #expect(output.contains("emailAddress: jane@example.com")) - } - - @Test("Format UserInfo with special characters in name") - func formatUserWithSpecialCharsInName() throws { - let user = UserInfo.test( - userRecordName: "user-003", - firstName: "O'Brien", - lastName: "Smith: Jr." - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("firstName: O'Brien")) - #expect(output.contains("\"Smith: Jr.\"")) // Colon should cause quoting - } - - @Test("Format UserInfo with email containing special chars") - func formatUserWithSpecialCharsInEmail() throws { - let user = UserInfo.test( - userRecordName: "user-004", - emailAddress: "test+tag@example.com" - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(user) - - #expect(output.contains("emailAddress: test+tag@example.com")) - } - - // MARK: - Edge Cases - - @Test("Format record name with YAML keywords") - func formatRecordNameWithYAMLKeywords() throws { - let record = RecordInfo( - recordName: "true", - recordType: "yes", - fields: [:] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // YAML keywords should be quoted - #expect(output.contains("recordName: \"true\"")) - #expect(output.contains("recordType: \"yes\"")) - } - - @Test("Format with complex field types") - func formatWithComplexFieldTypes() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Complex", - fields: [ - "reference": .reference(.init(recordName: "ref-001")), - "location": .location(.init(latitude: 37.7749, longitude: -122.4194)) - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Complex types should be converted to string representation - #expect(output.contains(" location:")) - #expect(output.contains(" reference:")) - } - - @Test("YAML structure verification") - func verifyYAMLStructure() throws { - let record = RecordInfo( - recordName: "verify-001", - recordType: "Verify", - fields: [ - "field1": .string("value1"), - "field2": .string("value2") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } - - // Verify YAML structure - #expect(lines[0].hasPrefix("recordName:")) - #expect(lines[1].hasPrefix("recordType:")) - #expect(lines[2] == "fields:") - #expect(lines[3].hasPrefix(" ")) // First field should be indented - } - - @Test("Format fallback to JSON for unknown type") - func formatUnknownType() throws { - struct UnknownType: Encodable { - let data: String - } - - let unknown = UnknownType(data: "test") - let formatter = YAMLFormatter() - - let output = try formatter.format(unknown) - - // Should fall back to pretty JSON format - #expect(output.contains("data")) - #expect(output.contains("test")) - } - - @Test("Format RecordInfo with list field") - func formatRecordWithListField() throws { - let record = RecordInfo( - recordName: "list-001", - recordType: "List", - fields: [ - "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - #expect(output.contains(" tags:")) - } - - @Test("Format simple value requiring no escaping") - func formatSimpleValue() throws { - let record = RecordInfo( - recordName: "simple-001", - recordType: "Simple", - fields: [ - "title": .string("SimpleTitle"), - "status": .string("active") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // Simple values should not be quoted - #expect(output.contains(" title: SimpleTitle")) - #expect(output.contains(" status: active")) - #expect(!output.contains("\"SimpleTitle\"")) - #expect(!output.contains("\"active\"")) - } - - @Test("Format RecordInfo with case variations of YAML keywords") - func formatRecordWithKeywordCaseVariations() throws { - let record = RecordInfo( - recordName: "rec-001", - recordType: "Keywords", - fields: [ - "field1": .string("Yes"), - "field2": .string("No"), - "field3": .string("True"), - "field4": .string("False"), - "field5": .string("ON"), - "field6": .string("OFF") - ] - ) - let formatter = YAMLFormatter() - - let output = try formatter.format(record) - - // All case variations of YAML keywords should be quoted - #expect(output.contains("\"Yes\"")) - #expect(output.contains("\"No\"")) - #expect(output.contains("\"True\"")) - #expect(output.contains("\"False\"")) - #expect(output.contains("\"ON\"")) - #expect(output.contains("\"OFF\"")) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/JSONFormatterTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/JSONFormatterTests.swift index 4de4c1d6..312601ee 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/JSONFormatterTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/JSONFormatterTests.swift @@ -30,28 +30,28 @@ import Foundation import Testing -@testable import MistDemo +@testable import MistDemoKit @Suite("JSONFormatter Tests") -struct JSONFormatterTests { +internal struct JSONFormatterTests { // MARK: - Test Data - struct TestUser: Codable { - let name: String - let age: Int - let email: String + internal struct TestUser: Codable { + internal let name: String + internal let age: Int + internal let email: String } - struct TestRecord: Codable { - let recordName: String - let recordType: String - let fields: [String: String] + internal struct TestRecord: Codable { + internal let recordName: String + internal let recordType: String + internal let fields: [String: String] } // MARK: - Basic Formatting Tests @Test("Format simple object without pretty printing") - func formatSimpleObject() throws { + internal func formatSimpleObject() throws { let user = TestUser(name: "Alice", age: 30, email: "alice@example.com") let formatter = JSONFormatter(pretty: false) @@ -66,7 +66,7 @@ struct JSONFormatterTests { } @Test("Format simple object with pretty printing") - func formatSimpleObjectPretty() throws { + internal func formatSimpleObjectPretty() throws { let user = TestUser(name: "Bob", age: 25, email: "bob@example.com") let formatter = JSONFormatter(pretty: true) @@ -80,10 +80,10 @@ struct JSONFormatterTests { } @Test("Format array of objects") - func formatArrayOfObjects() throws { + internal func formatArrayOfObjects() throws { let users = [ TestUser(name: "Charlie", age: 35, email: "charlie@example.com"), - TestUser(name: "Diana", age: 28, email: "diana@example.com") + TestUser(name: "Diana", age: 28, email: "diana@example.com"), ] let formatter = JSONFormatter(pretty: true) @@ -98,7 +98,7 @@ struct JSONFormatterTests { // MARK: - Edge Cases @Test("Format empty array") - func formatEmptyArray() throws { + internal func formatEmptyArray() throws { let emptyArray: [TestUser] = [] let formatter = JSONFormatter(pretty: false) @@ -108,7 +108,7 @@ struct JSONFormatterTests { } @Test("Format object with special characters") - func formatObjectWithSpecialCharacters() throws { + internal func formatObjectWithSpecialCharacters() throws { let user = TestUser( name: "Test \"User\"", age: 42, @@ -123,13 +123,13 @@ struct JSONFormatterTests { } @Test("Format object with nested structure") - func formatNestedObject() throws { + internal func formatNestedObject() throws { let record = TestRecord( recordName: "todo-123", recordType: "TodoItem", fields: [ "title": "Buy groceries", - "status": "pending" + "status": "pending", ] ) let formatter = JSONFormatter(pretty: true) @@ -145,7 +145,7 @@ struct JSONFormatterTests { // MARK: - Pretty Printing Tests @Test("Pretty printing produces sorted keys") - func prettyPrintingSortsKeys() throws { + internal func prettyPrintingSortsKeys() throws { let user = TestUser(name: "Zoe", age: 40, email: "zoe@example.com") let formatter = JSONFormatter(pretty: true) @@ -167,7 +167,7 @@ struct JSONFormatterTests { } @Test("Non-pretty printing is compact") - func nonPrettyPrintingIsCompact() throws { + internal func nonPrettyPrintingIsCompact() throws { let user = TestUser(name: "Frank", age: 50, email: "frank@example.com") let formatter = JSONFormatter(pretty: false) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/OutputEscapingDeprecatedTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/OutputEscapingDeprecatedTests.swift deleted file mode 100644 index a29a4b22..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/OutputEscapingDeprecatedTests.swift +++ /dev/null @@ -1,323 +0,0 @@ -// -// OutputEscapingDeprecatedTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -/// Tests for deprecated OutputEscaping APIs -/// These tests ensure backward compatibility during deprecation period -@Suite("OutputEscaping Deprecated API Tests") -struct OutputEscapingDeprecatedTests { - - // MARK: - CSV Escaping Tests - - @Test("CSV escape handles simple strings without special characters") - func csvEscapeSimpleString() { - let input = "simple text" - let result = OutputEscaping.csvEscape(input) - #expect(result == "simple text") - } - - @Test("CSV escape handles comma") - func csvEscapeComma() { - let input = "text,with,commas" - let result = OutputEscaping.csvEscape(input) - #expect(result == "\"text,with,commas\"") - } - - @Test("CSV escape handles quotes") - func csvEscapeQuotes() { - let input = "text with \"quotes\"" - let result = OutputEscaping.csvEscape(input) - #expect(result == "\"text with \"\"quotes\"\"\"") - } - - @Test("CSV escape handles newlines") - func csvEscapeNewlines() { - let input = "text\nwith\nnewlines" - let result = OutputEscaping.csvEscape(input) - #expect(result == "\"text\nwith\nnewlines\"") - } - - @Test("CSV escape handles tabs") - func csvEscapeTabs() { - let input = "text\twith\ttabs" - let result = OutputEscaping.csvEscape(input) - #expect(result == "\"text\twith\ttabs\"") - } - - @Test("CSV escape handles carriage returns") - func csvEscapeCarriageReturns() { - let input = "text\rwith\rCR" - let result = OutputEscaping.csvEscape(input) - #expect(result == "\"text\rwith\rCR\"") - } - - @Test("CSV escape handles mixed special characters") - func csvEscapeMixed() { - let input = "Name: \"John, Jr.\"\nAge: 30" - let result = OutputEscaping.csvEscape(input) - #expect(result == "\"Name: \"\"John, Jr.\"\"\nAge: 30\"") - } - - @Test("CSV escape handles empty string") - func csvEscapeEmpty() { - let input = "" - let result = OutputEscaping.csvEscape(input) - #expect(result == "") - } - - @Test("CSV escape is idempotent for simple strings") - func csvEscapeIdempotent() { - let input = "simple text" - let once = OutputEscaping.csvEscape(input) - let twice = OutputEscaping.csvEscape(once) - #expect(once == input) - #expect(twice == once) // Truly idempotent: no special chars, no quoting on second pass - } - - // MARK: - YAML Escaping Tests - - @Test("YAML escape handles simple strings") - func yamlEscapeSimpleString() { - let input = "simple text without special chars" - let result = OutputEscaping.yamlEscape(input) - #expect(result == input) - } - - @Test("YAML escape handles empty strings") - func yamlEscapeEmpty() { - let input = "" - let result = OutputEscaping.yamlEscape(input) - #expect(result == "\"\"") - } - - @Test("YAML escape handles special characters") - func yamlEscapeSpecialChars() { - let testCases: [(String, String)] = [ - (":", "\":\""), - ("#comment", "\"#comment\""), - ("@value", "\"@value\""), - ("[array]", "\"[array]\""), - ("{object}", "\"{object}\"") - ] - - for (input, expected) in testCases { - let result = OutputEscaping.yamlEscape(input) - #expect(result == expected, "Failed for input: \(input)") - } - } - - @Test("YAML escape handles boolean-like strings") - func yamlEscapeBooleans() { - let boolLike = ["yes", "no", "true", "false", "on", "off", "YES", "NO", "True", "False"] - - for input in boolLike { - let result = OutputEscaping.yamlEscape(input) - #expect(result.hasPrefix("\"") && result.hasSuffix("\""), "Should escape: \(input)") - } - } - - @Test("YAML escape handles null-like strings") - func yamlEscapeNull() { - let nullLike = ["null", "NULL", "Null", "~"] - - for input in nullLike { - let result = OutputEscaping.yamlEscape(input) - #expect(result.hasPrefix("\"") && result.hasSuffix("\""), "Should escape: \(input)") - } - } - - @Test("YAML escape handles number-like strings") - func yamlEscapeNumbers() { - let numbers = ["123", "45.67", "0", "-42", "3.14159"] - - for input in numbers { - let result = OutputEscaping.yamlEscape(input) - #expect(result.hasPrefix("\"") && result.hasSuffix("\""), "Should escape: \(input)") - } - } - - @Test("YAML escape handles multiline strings with block scalar") - func yamlEscapeMultiline() { - let input = "line 1\nline 2\nline 3" - let result = OutputEscaping.yamlEscape(input) - - #expect(result.hasPrefix("|\n")) - #expect(result.contains(" line 1")) - #expect(result.contains(" line 2")) - #expect(result.contains(" line 3")) - } - - @Test("YAML escape handles strings with leading whitespace") - func yamlEscapeLeadingWhitespace() { - let input = " leading spaces" - let result = OutputEscaping.yamlEscape(input) - #expect(result.hasPrefix("\"")) - } - - @Test("YAML escape handles strings with trailing whitespace") - func yamlEscapeTrailingWhitespace() { - let input = "trailing spaces " - let result = OutputEscaping.yamlEscape(input) - #expect(result.hasPrefix("\"")) - } - - @Test("YAML escape handles backslashes") - func yamlEscapeBackslash() { - let input = "path\\to\\file" - let result = OutputEscaping.yamlEscape(input) - #expect(result == "\"path\\\\to\\\\file\"") - } - - @Test("YAML escape handles quotes") - func yamlEscapeQuotes() { - let input = "text with \"quotes\"" - let result = OutputEscaping.yamlEscape(input) - #expect(result == "\"text with \\\"quotes\\\"\"") - } - - @Test("YAML escape handles tabs") - func yamlEscapeTabs() { - let input = "text\twith\ttabs" - let result = OutputEscaping.yamlEscape(input) - #expect(result.contains("\\t")) - } - - // MARK: - JSON Escaping Tests - - @Test("JSON escape handles simple strings") - func jsonEscapeSimpleString() { - let input = "simple text" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "simple text") - } - - @Test("JSON escape handles backslashes") - func jsonEscapeBackslash() { - let input = "path\\to\\file" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "path\\\\to\\\\file") - } - - @Test("JSON escape handles quotes") - func jsonEscapeQuotes() { - let input = "text with \"quotes\"" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "text with \\\"quotes\\\"") - } - - @Test("JSON escape handles newlines") - func jsonEscapeNewlines() { - let input = "line 1\nline 2" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "line 1\\nline 2") - } - - @Test("JSON escape handles carriage returns") - func jsonEscapeCarriageReturns() { - let input = "text\rwith\rCR" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "text\\rwith\\rCR") - } - - @Test("JSON escape handles tabs") - func jsonEscapeTabs() { - let input = "text\twith\ttabs" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "text\\twith\\ttabs") - } - - @Test("JSON escape handles form feed") - func jsonEscapeFormFeed() { - let input = "text\u{000C}with\u{000C}FF" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "text\\fwith\\fFF") - } - - @Test("JSON escape handles backspace") - func jsonEscapeBackspace() { - let input = "text\u{0008}with\u{0008}BS" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "text\\bwith\\bBS") - } - - @Test("JSON escape handles all control characters") - func jsonEscapeAllControls() { - let input = "\\\"\n\r\t\u{000C}\u{0008}" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "\\\\\\\"\\n\\r\\t\\f\\b") - } - - @Test("JSON escape handles empty string") - func jsonEscapeEmpty() { - let input = "" - let result = OutputEscaping.jsonEscape(input) - #expect(result == "") - } - - @Test("JSON escape handles unicode") - func jsonEscapeUnicode() { - let input = "Hello 🌍 World" - let result = OutputEscaping.jsonEscape(input) - // Unicode should pass through (JSONEncoder handles this) - #expect(result == "Hello 🌍 World") - } - - // MARK: - Edge Cases - - @Test("CSV escape handles unicode") - func csvEscapeUnicode() { - let input = "Hello 🌍 World" - let result = OutputEscaping.csvEscape(input) - #expect(result == "Hello 🌍 World") - } - - @Test("YAML escape handles unicode") - func yamlEscapeUnicode() { - let input = "Hello 🌍 World" - let result = OutputEscaping.yamlEscape(input) - #expect(result == "Hello 🌍 World") - } - - @Test("All escapers handle very long strings") - func escapeVeryLongStrings() { - let input = String(repeating: "a", count: 10000) - - let csv = OutputEscaping.csvEscape(input) - #expect(csv == input) - - let yaml = OutputEscaping.yamlEscape(input) - #expect(yaml == input) - - let json = OutputEscaping.jsonEscape(input) - #expect(json == input) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift new file mode 100644 index 00000000..d7dc640a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -0,0 +1,209 @@ +// +// MockBackend.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import MistKit + + @testable import MistDemoKit + + /// In-memory `WebBackend` for routing-level tests. Records the last + /// call to each operation and returns deterministic stub records. + internal final actor MockBackend: WebBackend { + internal struct QueryCall: Sendable { + internal let recordType: String + internal let limit: Int? + internal let sortBy: [WebRequests.QuerySortField]? + internal let database: MistKit.Database + } + + internal struct CreateCall: Sendable { + internal let recordType: String + internal let fields: [String: String] + internal let database: MistKit.Database + } + + internal struct UpdateCall: Sendable { + internal let recordType: String + internal let recordName: String + internal let fields: [String: String] + internal let recordChangeTag: String? + internal let database: MistKit.Database + } + + internal struct DeleteCall: Sendable { + internal let recordType: String + internal let recordName: String + internal let recordChangeTag: String? + internal let database: MistKit.Database + } + + internal private(set) var lastQuery: QueryCall? + internal private(set) var lastCreate: CreateCall? + internal private(set) var lastUpdate: UpdateCall? + internal private(set) var lastDelete: DeleteCall? + private var pendingError: String? + + private static func stubRecord( + recordType: String, recordName: String + ) -> RecordInfo { + let json = """ + { + "recordName": "\(recordName)", + "recordType": "\(recordType)", + "recordChangeTag": null, + "fields": {}, + "created": null, + "modified": null, + "deleted": false + } + """ + // RecordInfo is Codable; round-trip through JSON keeps the stub + // independent of MistKit's internal initializer. + do { + return try JSONDecoder().decode( + RecordInfo.self, from: Data(json.utf8) + ) + } catch { + fatalError("MockBackend stubRecord JSON failed to decode: \(error)") + } + } + + /// Flatten FieldValue entries into a printable form so tests can write + /// `#expect(captured.fields["title"] == "Hi")` for strings or + /// `#expect(captured.fields["index"] == "5")` for numbers without + /// pattern-matching on FieldValue in every assertion. + /// + /// Non-primitive cases (asset, date, reference, location, list, bytes) + /// are intentionally dropped — they yield no useful String form for an + /// equality assertion. Tests that need to assert those types should + /// inspect the FieldValue directly rather than going through `flatten`. + private static func flatten( + _ fields: [String: FieldValue] + ) -> [String: String] { + var result: [String: String] = [:] + for (name, value) in fields { + switch value { + case .string(let string): + result[name] = string + case .int64(let int): + result[name] = String(int) + case .double(let double): + result[name] = String(double) + default: + continue + } + } + return result + } + + internal func failNext(message: String) { + pendingError = message + } + + internal func webQuery( + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database + ) async throws -> [RecordInfo] { + lastQuery = QueryCall( + recordType: recordType, + limit: limit, + sortBy: sortBy, + database: database + ) + try consumePendingError() + return [ + Self.stubRecord(recordType: recordType, recordName: "stub-1") + ] + } + + internal func webCreate( + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo { + lastCreate = CreateCall( + recordType: recordType, + fields: Self.flatten(fields), + database: database + ) + try consumePendingError() + return Self.stubRecord( + recordType: recordType, recordName: "created-1" + ) + } + + internal func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String?, + database: MistKit.Database + ) async throws -> RecordInfo { + lastUpdate = UpdateCall( + recordType: recordType, + recordName: recordName, + fields: Self.flatten(fields), + recordChangeTag: recordChangeTag, + database: database + ) + try consumePendingError() + return Self.stubRecord( + recordType: recordType, recordName: recordName + ) + } + + internal func webDelete( + recordType: String, + recordName: String, + recordChangeTag: String?, + database: MistKit.Database + ) async throws { + lastDelete = DeleteCall( + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag, + database: database + ) + try consumePendingError() + } + + private func consumePendingError() throws { + if let message = pendingError { + pendingError = nil + struct StubError: LocalizedError { + let errorDescription: String? + } + throw StubError(errorDescription: message) + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift new file mode 100644 index 00000000..c83dca9e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift @@ -0,0 +1,68 @@ +// +// WebAuthTokenStoreTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Testing + + @testable import MistDemoKit + + @Suite("WebAuthTokenStore Tests") + internal struct WebAuthTokenStoreTests { + @Test("Starts empty when initialized without a token") + internal func startsEmpty() async { + let store = WebAuthTokenStore() + let value = await store.currentToken + #expect(value == nil) + } + + @Test("Returns the token passed to the initializer") + internal func preSeeded() async { + let store = WebAuthTokenStore(token: "seed") + let value = await store.currentToken + #expect(value == "seed") + } + + @Test("update(_:) replaces the stored token") + internal func updateReplaces() async { + let store = WebAuthTokenStore() + await store.update("first") + await store.update("second") + let value = await store.currentToken + #expect(value == "second") + } + + @Test("clear() removes the stored token") + internal func clearRemoves() async { + let store = WebAuthTokenStore(token: "tok") + await store.clear() + let value = await store.currentToken + #expect(value == nil) + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift new file mode 100644 index 00000000..aa90f059 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift @@ -0,0 +1,56 @@ +// +// WebJSONTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import Testing + + @testable import MistDemoKit + + @Suite("WebJSON") + internal struct WebJSONTests { + private struct DateWrapper: Codable { + let date: Date + } + + @Test("encoder writes Date as epoch-millis numbers") + internal func encoderEmitsEpochMillis() throws { + // 1500ms since 1970-01-01T00:00:00Z — chosen so the expected JSON + // value is a plain integer the browser's `new Date(1500)` can consume. + let date = Date(timeIntervalSince1970: 1.5) + + let data = try WebJSON.encoder().encode(DateWrapper(date: date)) + + let json = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + #expect(json["date"] as? Double == 1_500) + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift new file mode 100644 index 00000000..28afc0f9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift @@ -0,0 +1,225 @@ +// +// WebServerTests+CRUD.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + private struct RecordsPayload: Decodable { + let records: [RecordInfo] + } + + private struct DeletePayload: Decodable { + let recordName: String + let deleted: Bool + } + + @Test("POST /api/records/query forwards to the backend") + internal func queryForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"recordType":"Note","limit":10}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + RecordsPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.records.count == 1) + #expect(payload.records.first?.recordType == "Note") + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.recordType == "Note") + #expect(captured?.limit == 10) + #expect(captured?.database == .private) + } + + @Test("POST /api/records/create forwards fields to the backend") + internal func createForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"recordType":"Note","fields":{"title":"Hi"}}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastCreate + #expect(captured?.recordType == "Note") + #expect(captured?.fields["title"] == "Hi") + } + + @Test("POST /api/records/create accepts JSON-number fields (Int + Double)") + internal func createAcceptsNumericFields() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","fields":{"title":"Hi","index":5,"score":1.5}} + """ + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + let captured = await fixture.backend.lastCreate + #expect(captured?.fields["title"] == "Hi") + #expect(captured?.fields["index"] == "5") + #expect(captured?.fields["score"] == "1.5") + } + + @Test("POST /api/records/update forwards recordName, fields, changeTag") + internal func updateForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"abc","fields":{"title":"Up"},\ + "recordChangeTag":"tag-1"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUpdate + #expect(captured?.recordType == "Note") + #expect(captured?.recordName == "abc") + #expect(captured?.fields["title"] == "Up") + #expect(captured?.recordChangeTag == "tag-1") + } + + @Test("POST /api/records/update accepts a missing recordChangeTag") + internal func updateAcceptsAbsentChangeTag() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"abc","fields":{"title":"Up"}} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUpdate + #expect(captured?.recordChangeTag == nil) + } + + @Test("POST /api/records/delete forwards recordName + changeTag") + internal func deleteForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #""" + {"recordType":"Note","recordName":"abc","recordChangeTag":"tag-9"} + """# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/delete", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + DeletePayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.recordName == "abc") + #expect(payload.deleted) + } + } + + let captured = await fixture.backend.lastDelete + #expect(captured?.recordType == "Note") + #expect(captured?.recordName == "abc") + #expect(captured?.recordChangeTag == "tag-9") + } + + @Test("Backend errors surface as 500 with a JSON message body") + internal func backendErrorIsSurfaced() async throws { + let fixture = Self.makeFixture(authenticated: true) + await fixture.backend.failNext(message: "boom") + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note"}"#) + ) { response in + #expect(response.status == .internalServerError) + let body = String(buffer: response.body) + #expect(body.contains("boom")) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift new file mode 100644 index 00000000..d1a7106f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift @@ -0,0 +1,133 @@ +// +// WebServerTests+Database.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("CRUD requests omit `database` → backend receives .private") + internal func crudDefaultsDatabaseToPrivate() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note"}"#) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.database == .private) + } + + @Test( + "CRUD requests forward `database`: public → backend", + arguments: [ + ("/api/records/query", #"{"recordType":"Note","database":"public"}"#), + ( + "/api/records/create", + #"{"recordType":"Note","database":"public","fields":{"title":"X"}}"# + ), + ( + "/api/records/update", + #""" + {"recordType":"Note","database":"public",\# + "recordName":"r1","fields":{"title":"X"}} + """# + ), + ( + "/api/records/delete", + #"{"recordType":"Note","database":"public","recordName":"r1"}"# + ), + ] + ) + internal func crudForwardsPublicDatabase( + path: String, + jsonBody: String + ) async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: path, + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured: MistKit.Database? + switch path { + case "/api/records/query": + captured = await fixture.backend.lastQuery?.database + case "/api/records/create": + captured = await fixture.backend.lastCreate?.database + case "/api/records/update": + captured = await fixture.backend.lastUpdate?.database + case "/api/records/delete": + captured = await fixture.backend.lastDelete?.database + default: + captured = nil + } + #expect(captured == .public(.prefers(.serverToServer))) + } + + @Test("CRUD requests with an unknown `database` value return 400") + internal func crudRejectsUnknownDatabase() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note","database":"bogus"}"#) + ) { response in + #expect(response.status == .badRequest) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift new file mode 100644 index 00000000..c58a5c22 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -0,0 +1,121 @@ +// +// WebServerTests+Index.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("GET / returns the web demo HTML") + internal func indexReturnsHtml() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains("MistKit Web Demo")) + } + } + } + + @Test("Index HTML wires CloudKit JS as an alternate backend") + internal func indexExposesCloudKitJsHandlers() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains("cdn.apple-cloudkit.com/ck/2/cloudkit.js")) + #expect(!body.contains("id=\"mode-cloudkitjs\" type=\"button\" disabled")) + #expect(body.contains("performQuery")) + #expect(body.contains("saveRecords")) + #expect(body.contains("deleteRecords")) + #expect(!body.contains("cloudKitJsNotWired")) + } + } + } + + @Test("Index HTML exposes a public/private database picker") + internal func indexExposesDatabasePicker() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains(#"id="db-private""#)) + #expect(body.contains(#"id="db-public""#)) + #expect(body.contains("publicCloudDatabase")) + #expect(body.contains("privateCloudDatabase")) + } + } + } + + @Test("Index HTML carries the post-database-picker UX additions") + internal func indexCarriesUxPolish() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + // 1. Loading state + #expect(body.contains(".status.loading")) + #expect(body.contains("queryInFlight")) + #expect(body.contains("setQueryControlsDisabled")) + // 2. Post-create delay + #expect(body.contains("REFRESH_DELAY_MS")) + #expect(body.contains("waiting")) + // 3. "You" badge wired to the captured user identity + #expect(body.contains("currentUserRecordName")) + #expect(body.contains("badge-you")) + #expect(body.contains("extractUserRecordName")) + // 4. Default sort = ___createTime descending + #expect( + body.contains( + "currentSort = { field: '___createTime', ascending: false }" + ) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift new file mode 100644 index 00000000..8db89013 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift @@ -0,0 +1,87 @@ +// +// WebServerTests+QuerySort.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("POST /api/records/query forwards sortBy to the backend") + internal func queryForwardsSort() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","sortBy":[\ + {"field":"___modTime","ascending":false}]} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.sortBy?.count == 1) + #expect(captured?.sortBy?.first?.field == "___modTime") + #expect(captured?.sortBy?.first?.ascending == false) + } + + @Test("POST /api/records/query without sortBy passes nil") + internal func queryWithoutSortIsNil() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note"}"#) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.sortBy == nil) + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift new file mode 100644 index 00000000..4d1b1bee --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift @@ -0,0 +1,222 @@ +// +// WebServerTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + @Suite("WebServer Tests") + internal struct WebServerTests { + internal struct Fixture { + internal let server: WebServer + internal let tokenStore: WebAuthTokenStore + internal let backend: MockBackend + } + + private struct ConfigPayload: Decodable { + let apiToken: String + let containerIdentifier: String + let environment: String + let publicDatabaseAvailable: Bool + } + + internal static func makeFixture( + authenticated: Bool = false, + terminatesAfterAuth: Bool = false, + publicDatabaseAvailable: Bool = false + ) -> Fixture { + let backend = MockBackend() + let store = WebAuthTokenStore( + token: authenticated ? "captured-token" : nil + ) + let factory = WebBackendFactory { _ in backend } + let server = WebServer( + apiToken: "test-api-token", + containerIdentifier: "iCloud.test.container", + environment: .development, + publicDatabaseAvailable: publicDatabaseAvailable, + tokenStore: store, + backendFactory: factory, + terminatesAfterAuth: terminatesAfterAuth + ) + return Fixture(server: server, tokenStore: store, backend: backend) + } + + @Test("GET /api/config returns container + environment") + internal func configIncludesEnvironment() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/api/config", method: .get) { + response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + ConfigPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.apiToken == "test-api-token") + #expect(payload.containerIdentifier == "iCloud.test.container") + #expect(payload.environment == "development") + #expect(payload.publicDatabaseAvailable == false) + } + } + } + + @Test("GET /api/config advertises publicDatabaseAvailable when S2S configured") + internal func configAdvertisesPublicDatabase() async throws { + let fixture = Self.makeFixture(publicDatabaseAvailable: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/api/config", method: .get) { + response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + ConfigPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.publicDatabaseAvailable == true) + } + } + } + + @Test("POST /api/authenticate captures the token and returns 204") + internal func authenticateCapturesToken() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + let body = try JSONEncoder().encode([ + "sessionToken": "session-xyz", + "userRecordName": "_abc", + ]) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/authenticate", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(bytes: body) + ) { response in + #expect(response.status == .noContent) + #expect(response.body.readableBytes == 0) + } + } + + let stored = await fixture.tokenStore.currentToken + #expect(stored == "session-xyz") + } + + @Test("POST /api/authenticate returns 205 when terminatesAfterAuth") + internal func authenticateReturns205WhenTerminating() async throws { + let fixture = Self.makeFixture(terminatesAfterAuth: true) + let app = Application(router: try fixture.server.makeRouter()) + + let body = try JSONEncoder().encode([ + "sessionToken": "session-xyz", + "userRecordName": "_abc", + ]) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/authenticate", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(bytes: body) + ) { response in + #expect(response.status == .resetContent) + #expect(response.body.readableBytes == 0) + } + } + + let stored = await fixture.tokenStore.currentToken + #expect(stored == "session-xyz") + } + + @Test("tokenUpdates yields the captured token after authenticate") + internal func authenticateYieldsToTokenUpdates() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + let body = try JSONEncoder().encode([ + "sessionToken": "session-xyz", + "userRecordName": "_abc", + ]) + + try await app.test(.router) { client in + async let firstToken: String? = { + var iterator = fixture.tokenStore.tokenUpdates.makeAsyncIterator() + return await iterator.next() + }() + + try await client.execute( + uri: "/api/authenticate", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(bytes: body) + ) { response in + #expect(response.status == .noContent) + } + + #expect(await firstToken == "session-xyz") + } + } + + @Test( + "CRUD routes return 401 when no auth token has been captured", + arguments: [ + "/api/records/query", + "/api/records/create", + "/api/records/update", + "/api/records/delete", + ] + ) + internal func crudRejectsPreAuth(path: String) async throws { + let fixture = Self.makeFixture(authenticated: false) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: path, + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: "{}") + ) { response in + #expect(response.status == .unauthorized) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+BooleanDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+BooleanDecoding.swift new file mode 100644 index 00000000..484c2ee8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+BooleanDecoding.swift @@ -0,0 +1,54 @@ +// +// AnyCodableTests+BooleanDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Boolean Decoding") + internal struct BooleanDecoding { + @Test("Decode true") + internal func decodeTrue() throws { + let json = "true" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Bool == true) + } + + @Test("Decode false") + internal func decodeFalse() throws { + let json = "false" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Bool == false) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+DoubleDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+DoubleDecoding.swift new file mode 100644 index 00000000..491c586f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+DoubleDecoding.swift @@ -0,0 +1,62 @@ +// +// AnyCodableTests+DoubleDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Double Decoding") + internal struct DoubleDecoding { + @Test("Decode positive double") + internal func decodePositiveDouble() throws { + let json = "3.14" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Double == 3.14) + } + + @Test("Decode negative double") + internal func decodeNegativeDouble() throws { + let json = "-2.5" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Double == -2.5) + } + + @Test("Decode double with scientific notation") + internal func decodeScientificNotation() throws { + let json = "1.23e-4" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Double == 1.23e-4) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+Encoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+Encoding.swift new file mode 100644 index 00000000..15257a0f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+Encoding.swift @@ -0,0 +1,78 @@ +// +// AnyCodableTests+Encoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Encoding") + internal struct Encoding { + @Test("Encode string value") + internal func encodeString() throws { + let anyCodable = try AnyCodable(value: "test") + let data = try JSONEncoder().encode(anyCodable) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json == "\"test\"") + } + + @Test("Encode integer value") + internal func encodeInteger() throws { + let anyCodable = try AnyCodable(value: 123) + let data = try JSONEncoder().encode(anyCodable) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json == "123") + } + + @Test("Encode double value") + internal func encodeDouble() throws { + let anyCodable = try AnyCodable(value: 3.14) + let data = try JSONEncoder().encode(anyCodable) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("3.14")) + } + + @Test("Encode boolean value") + internal func encodeBoolean() throws { + let anyCodable = try AnyCodable(value: true) + let data = try JSONEncoder().encode(anyCodable) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json == "true") + } + + @Test("Encode null value") + internal func encodeNull() throws { + let anyCodable = try AnyCodable(value: NSNull()) + let data = try JSONEncoder().encode(anyCodable) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json == "null") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+Errors.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+Errors.swift new file mode 100644 index 00000000..34a594ba --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+Errors.swift @@ -0,0 +1,55 @@ +// +// AnyCodableTests+Errors.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Errors") + internal struct Errors { + @Test("Decode invalid value throws error") + internal func decodeInvalidValue() throws { + let json = "[1, 2, 3]" // Arrays not supported + let data = Data(json.utf8) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(AnyCodable.self, from: data) + } + } + + @Test("Encode unsupported type throws error at init") + internal func encodeUnsupportedType() { + struct CustomType {} + #expect(throws: DecodingError.self) { + try AnyCodable(value: CustomType()) + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+IntegerDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+IntegerDecoding.swift new file mode 100644 index 00000000..943f51a6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+IntegerDecoding.swift @@ -0,0 +1,62 @@ +// +// AnyCodableTests+IntegerDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Integer Decoding") + internal struct IntegerDecoding { + @Test("Decode positive integer") + internal func decodePositiveInt() throws { + let json = "42" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Int == 42) + } + + @Test("Decode negative integer") + internal func decodeNegativeInt() throws { + let json = "-123" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Int == -123) + } + + @Test("Decode zero") + internal func decodeZero() throws { + let json = "0" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Int == 0) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+NullDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+NullDecoding.swift new file mode 100644 index 00000000..339b2984 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+NullDecoding.swift @@ -0,0 +1,46 @@ +// +// AnyCodableTests+NullDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Null Decoding") + internal struct NullDecoding { + @Test("Decode null value") + internal func decodeNull() throws { + let json = "null" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value is NSNull) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+RoundTrip.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+RoundTrip.swift new file mode 100644 index 00000000..8bc63c8f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+RoundTrip.swift @@ -0,0 +1,74 @@ +// +// AnyCodableTests+RoundTrip.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("Round-trip") + internal struct RoundTrip { + @Test("Round-trip string value") + internal func roundTripString() throws { + let original = "hello" + let anyCodable = try AnyCodable(value: original) + let data = try JSONEncoder().encode(anyCodable) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? String == original) + } + + @Test("Round-trip integer value") + internal func roundTripInteger() throws { + let original = 42 + let anyCodable = try AnyCodable(value: original) + let data = try JSONEncoder().encode(anyCodable) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Int == original) + } + + @Test("Round-trip double value") + internal func roundTripDouble() throws { + let original = 3.14159 + let anyCodable = try AnyCodable(value: original) + let data = try JSONEncoder().encode(anyCodable) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Double == original) + } + + @Test("Round-trip boolean value") + internal func roundTripBoolean() throws { + let original = true + let anyCodable = try AnyCodable(value: original) + let data = try JSONEncoder().encode(anyCodable) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? Bool == original) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+StringDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+StringDecoding.swift new file mode 100644 index 00000000..bda3eae4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests+StringDecoding.swift @@ -0,0 +1,54 @@ +// +// AnyCodableTests+StringDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AnyCodableTests { + @Suite("String Decoding") + internal struct StringDecoding { + @Test("Decode string value") + internal func decodeString() throws { + let json = "\"hello world\"" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect(decoded.value as? String == "hello world") + } + + @Test("Decode empty string") + internal func decodeEmptyString() throws { + let json = "\"\"" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + #expect((decoded.value as? String)?.isEmpty == true) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests.swift new file mode 100644 index 00000000..1f38ed80 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodable/AnyCodableTests.swift @@ -0,0 +1,73 @@ +// +// AnyCodableTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("AnyCodable") +internal enum AnyCodableTests {} + +// MARK: - AnyCodable Test Helper + +extension AnyCodable { + /// Test helper that creates AnyCodable by encoding and decoding a value + internal init(value: Any) throws { + // For simple Codable types, encode to JSON and decode as AnyCodable + struct Wrapper: Codable { + let value: AnyCodable + } + + // Encode the value to JSON data + let jsonData: Data + if let stringValue = value as? String { + jsonData = try JSONEncoder().encode(stringValue) + } else if let intValue = value as? Int { + jsonData = try JSONEncoder().encode(intValue) + } else if let doubleValue = value as? Double { + jsonData = try JSONEncoder().encode(doubleValue) + } else if let boolValue = value as? Bool { + jsonData = try JSONEncoder().encode(boolValue) + } else if value is NSNull { + jsonData = Data("null".utf8) + } else { + // For other types, fail gracefully + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: [], + debugDescription: "Unsupported type for test helper: \(type(of: value))" + ) + ) + } + + // Decode as AnyCodable + self = try JSONDecoder().decode(AnyCodable.self, from: jsonData) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodableTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodableTests.swift deleted file mode 100644 index 10f15392..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Types/AnyCodableTests.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// AnyCodableTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("AnyCodable Tests") -struct AnyCodableTests { - - // MARK: - String Decoding Tests - - @Test("Decode string value") - func decodeString() throws { - let json = "\"hello world\"" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? String == "hello world") - } - - @Test("Decode empty string") - func decodeEmptyString() throws { - let json = "\"\"" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? String == "") - } - - // MARK: - Integer Decoding Tests - - @Test("Decode positive integer") - func decodePositiveInt() throws { - let json = "42" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Int == 42) - } - - @Test("Decode negative integer") - func decodeNegativeInt() throws { - let json = "-123" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Int == -123) - } - - @Test("Decode zero") - func decodeZero() throws { - let json = "0" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Int == 0) - } - - // MARK: - Double Decoding Tests - - @Test("Decode positive double") - func decodePositiveDouble() throws { - let json = "3.14" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Double == 3.14) - } - - @Test("Decode negative double") - func decodeNegativeDouble() throws { - let json = "-2.5" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Double == -2.5) - } - - @Test("Decode double with scientific notation") - func decodeScientificNotation() throws { - let json = "1.23e-4" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Double == 1.23e-4) - } - - // MARK: - Boolean Decoding Tests - - @Test("Decode true") - func decodeTrue() throws { - let json = "true" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Bool == true) - } - - @Test("Decode false") - func decodeFalse() throws { - let json = "false" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Bool == false) - } - - // MARK: - Null Decoding Tests - - @Test("Decode null value") - func decodeNull() throws { - let json = "null" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value is NSNull) - } - - // MARK: - Encoding Tests - - @Test("Encode string value") - func encodeString() throws { - let anyCodable = try AnyCodable(value: "test") - let data = try JSONEncoder().encode(anyCodable) - let json = String(data: data, encoding: .utf8)! - #expect(json == "\"test\"") - } - - @Test("Encode integer value") - func encodeInteger() throws { - let anyCodable = try AnyCodable(value: 123) - let data = try JSONEncoder().encode(anyCodable) - let json = String(data: data, encoding: .utf8)! - #expect(json == "123") - } - - @Test("Encode double value") - func encodeDouble() throws { - let anyCodable = try AnyCodable(value: 3.14) - let data = try JSONEncoder().encode(anyCodable) - let json = String(data: data, encoding: .utf8)! - #expect(json.contains("3.14")) - } - - @Test("Encode boolean value") - func encodeBoolean() throws { - let anyCodable = try AnyCodable(value: true) - let data = try JSONEncoder().encode(anyCodable) - let json = String(data: data, encoding: .utf8)! - #expect(json == "true") - } - - @Test("Encode null value") - func encodeNull() throws { - let anyCodable = try AnyCodable(value: NSNull()) - let data = try JSONEncoder().encode(anyCodable) - let json = String(data: data, encoding: .utf8)! - #expect(json == "null") - } - - // MARK: - Error Tests - - @Test("Decode invalid value throws error") - func decodeInvalidValue() throws { - let json = "[1, 2, 3]" // Arrays not supported - let data = Data(json.utf8) - #expect(throws: DecodingError.self) { - try JSONDecoder().decode(AnyCodable.self, from: data) - } - } - - @Test("Encode unsupported type throws error at init") - func encodeUnsupportedType() { - struct CustomType {} - #expect(throws: DecodingError.self) { - try AnyCodable(value: CustomType()) - } - } - - // MARK: - Round-trip Tests - - @Test("Round-trip string value") - func roundTripString() throws { - let original = "hello" - let anyCodable = try AnyCodable(value: original) - let data = try JSONEncoder().encode(anyCodable) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? String == original) - } - - @Test("Round-trip integer value") - func roundTripInteger() throws { - let original = 42 - let anyCodable = try AnyCodable(value: original) - let data = try JSONEncoder().encode(anyCodable) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Int == original) - } - - @Test("Round-trip double value") - func roundTripDouble() throws { - let original = 3.14159 - let anyCodable = try AnyCodable(value: original) - let data = try JSONEncoder().encode(anyCodable) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Double == original) - } - - @Test("Round-trip boolean value") - func roundTripBoolean() throws { - let original = true - let anyCodable = try AnyCodable(value: original) - let data = try JSONEncoder().encode(anyCodable) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - #expect(decoded.value as? Bool == original) - } -} - -// MARK: - AnyCodable Test Helper - -extension AnyCodable { - /// Test helper that creates AnyCodable by encoding and decoding a value - init(value: Any) throws { - // For simple Codable types, encode to JSON and decode as AnyCodable - struct Wrapper: Codable { - let value: AnyCodable - } - - // Encode the value to JSON data - let jsonData: Data - if let stringValue = value as? String { - jsonData = try JSONEncoder().encode(stringValue) - } else if let intValue = value as? Int { - jsonData = try JSONEncoder().encode(intValue) - } else if let doubleValue = value as? Double { - jsonData = try JSONEncoder().encode(doubleValue) - } else if let boolValue = value as? Bool { - jsonData = try JSONEncoder().encode(boolValue) - } else if value is NSNull { - jsonData = "null".data(using: .utf8)! - } else { - // For other types, fail gracefully - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [], - debugDescription: "Unsupported type for test helper: \(type(of: value))" - ) - ) - } - - // Decode as AnyCodable - self = try JSONDecoder().decode(AnyCodable.self, from: jsonData) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+CodingKeyConformance.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+CodingKeyConformance.swift new file mode 100644 index 00000000..ab9271a3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+CodingKeyConformance.swift @@ -0,0 +1,95 @@ +// +// DynamicKeyTests+CodingKeyConformance.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension DynamicKeyTests { + @Suite("CodingKey Protocol Conformance") + internal struct CodingKeyConformance { + @Test("Use DynamicKey in decoding container") + internal func useInDecodingContainer() throws { + let json = """ + { + "dynamicField": "value", + "anotherField": 123 + } + """ + let data = Data(json.utf8) + + struct TestWrapper: Decodable { + let fields: [String: String] + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: DynamicKey.self) + var fields: [String: String] = [:] + + for key in container.allKeys { + if let value = try? container.decode(String.self, forKey: key) { + fields[key.stringValue] = value + } else if let intValue = try? container.decode(Int.self, forKey: key) { + fields[key.stringValue] = String(intValue) + } + } + + self.fields = fields + } + } + + let decoded = try JSONDecoder().decode(TestWrapper.self, from: data) + #expect(decoded.fields["dynamicField"] == "value") + #expect(decoded.fields["anotherField"] == "123") + } + + @Test("Use DynamicKey in encoding container") + internal func useInEncodingContainer() throws { + struct TestWrapper: Encodable { + let fields: [String: String] + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + + for (key, value) in fields { + guard let dynamicKey = DynamicKey(stringValue: key) else { continue } + try container.encode(value, forKey: dynamicKey) + } + } + } + + let wrapper = TestWrapper(fields: ["field1": "value1", "field2": "value2"]) + let data = try JSONEncoder().encode(wrapper) + let json = try JSONSerialization.jsonObject(with: data) as? [String: String] + + #expect(json?["field1"] == "value1") + #expect(json?["field2"] == "value2") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+Equality.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+Equality.swift new file mode 100644 index 00000000..0790cc3c --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+Equality.swift @@ -0,0 +1,51 @@ +// +// DynamicKeyTests+Equality.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +extension DynamicKeyTests { + @Suite("Equality") + internal struct Equality { + @Test("Keys with same string value are equal") + internal func keysWithSameStringEqual() { + let key1 = DynamicKey(stringValue: "test") + let key2 = DynamicKey(stringValue: "test") + #expect(key1?.stringValue == key2?.stringValue) + } + + @Test("Keys with different string values are not equal") + internal func keysWithDifferentStringNotEqual() { + let key1 = DynamicKey(stringValue: "test1") + let key2 = DynamicKey(stringValue: "test2") + #expect(key1?.stringValue != key2?.stringValue) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+IntegerInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+IntegerInitialization.swift new file mode 100644 index 00000000..0eaf2bdc --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+IntegerInitialization.swift @@ -0,0 +1,62 @@ +// +// DynamicKeyTests+IntegerInitialization.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension DynamicKeyTests { + @Suite("Integer Initialization") + internal struct IntegerInitialization { + @Test("Initialize with integer value") + internal func initWithIntValue() { + let key = DynamicKey(intValue: 42) + #expect(key != nil) + #expect(key?.stringValue == "42") + #expect(key?.intValue == 42) + } + + @Test("Initialize with zero") + internal func initWithZero() { + let key = DynamicKey(intValue: 0) + #expect(key != nil) + #expect(key?.stringValue == "0") + #expect(key?.intValue == 0) + } + + @Test("Initialize with negative integer") + internal func initWithNegativeInt() { + let key = DynamicKey(intValue: -5) + #expect(key != nil) + #expect(key?.stringValue == "-5") + #expect(key?.intValue == -5) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+StringInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+StringInitialization.swift new file mode 100644 index 00000000..933bfac0 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests+StringInitialization.swift @@ -0,0 +1,69 @@ +// +// DynamicKeyTests+StringInitialization.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension DynamicKeyTests { + @Suite("String Initialization") + internal struct StringInitialization { + @Test("Initialize with string value") + internal func initWithStringValue() { + let key = DynamicKey(stringValue: "testKey") + #expect(key != nil) + #expect(key?.stringValue == "testKey") + #expect(key?.intValue == nil) + } + + @Test("Initialize with empty string") + internal func initWithEmptyString() { + let key = DynamicKey(stringValue: "") + #expect(key != nil) + #expect(key?.stringValue.isEmpty == true) + #expect(key?.intValue == nil) + } + + @Test("Initialize with string containing numbers") + internal func initWithNumericString() { + let key = DynamicKey(stringValue: "123") + #expect(key != nil) + #expect(key?.stringValue == "123") + #expect(key?.intValue == nil) + } + + @Test("Initialize with string containing special characters") + internal func initWithSpecialCharacters() { + let key = DynamicKey(stringValue: "field_name-123") + #expect(key != nil) + #expect(key?.stringValue == "field_name-123") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests.swift new file mode 100644 index 00000000..de4b509a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKey/DynamicKeyTests.swift @@ -0,0 +1,33 @@ +// +// DynamicKeyTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("DynamicKey") +internal enum DynamicKeyTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKeyTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKeyTests.swift deleted file mode 100644 index 71bf442a..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Types/DynamicKeyTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// DynamicKeyTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("DynamicKey Tests") -struct DynamicKeyTests { - - // MARK: - String Initialization Tests - - @Test("Initialize with string value") - func initWithStringValue() { - let key = DynamicKey(stringValue: "testKey") - #expect(key != nil) - #expect(key?.stringValue == "testKey") - #expect(key?.intValue == nil) - } - - @Test("Initialize with empty string") - func initWithEmptyString() { - let key = DynamicKey(stringValue: "") - #expect(key != nil) - #expect(key?.stringValue == "") - #expect(key?.intValue == nil) - } - - @Test("Initialize with string containing numbers") - func initWithNumericString() { - let key = DynamicKey(stringValue: "123") - #expect(key != nil) - #expect(key?.stringValue == "123") - #expect(key?.intValue == nil) - } - - @Test("Initialize with string containing special characters") - func initWithSpecialCharacters() { - let key = DynamicKey(stringValue: "field_name-123") - #expect(key != nil) - #expect(key?.stringValue == "field_name-123") - } - - // MARK: - Integer Initialization Tests - - @Test("Initialize with integer value") - func initWithIntValue() { - let key = DynamicKey(intValue: 42) - #expect(key != nil) - #expect(key?.stringValue == "42") - #expect(key?.intValue == 42) - } - - @Test("Initialize with zero") - func initWithZero() { - let key = DynamicKey(intValue: 0) - #expect(key != nil) - #expect(key?.stringValue == "0") - #expect(key?.intValue == 0) - } - - @Test("Initialize with negative integer") - func initWithNegativeInt() { - let key = DynamicKey(intValue: -5) - #expect(key != nil) - #expect(key?.stringValue == "-5") - #expect(key?.intValue == -5) - } - - // MARK: - CodingKey Protocol Conformance Tests - - @Test("Use DynamicKey in decoding container") - func useInDecodingContainer() throws { - let json = """ - { - "dynamicField": "value", - "anotherField": 123 - } - """ - let data = Data(json.utf8) - - struct TestWrapper: Decodable { - let fields: [String: String] - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DynamicKey.self) - var fields: [String: String] = [:] - - for key in container.allKeys { - if let value = try? container.decode(String.self, forKey: key) { - fields[key.stringValue] = value - } else if let intValue = try? container.decode(Int.self, forKey: key) { - fields[key.stringValue] = String(intValue) - } - } - - self.fields = fields - } - } - - let decoded = try JSONDecoder().decode(TestWrapper.self, from: data) - #expect(decoded.fields["dynamicField"] == "value") - #expect(decoded.fields["anotherField"] == "123") - } - - @Test("Use DynamicKey in encoding container") - func useInEncodingContainer() throws { - struct TestWrapper: Encodable { - let fields: [String: String] - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: DynamicKey.self) - - for (key, value) in fields { - let dynamicKey = DynamicKey(stringValue: key)! - try container.encode(value, forKey: dynamicKey) - } - } - } - - let wrapper = TestWrapper(fields: ["field1": "value1", "field2": "value2"]) - let data = try JSONEncoder().encode(wrapper) - let json = try JSONSerialization.jsonObject(with: data) as? [String: String] - - #expect(json?["field1"] == "value1") - #expect(json?["field2"] == "value2") - } - - // MARK: - Equality Tests - - @Test("Keys with same string value are equal") - func keysWithSameStringEqual() { - let key1 = DynamicKey(stringValue: "test") - let key2 = DynamicKey(stringValue: "test") - #expect(key1?.stringValue == key2?.stringValue) - } - - @Test("Keys with different string values are not equal") - func keysWithDifferentStringNotEqual() { - let key1 = DynamicKey(stringValue: "test1") - let key2 = DynamicKey(stringValue: "test2") - #expect(key1?.stringValue != key2?.stringValue) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+BoolCase.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+BoolCase.swift new file mode 100644 index 00000000..c74e9c57 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+BoolCase.swift @@ -0,0 +1,56 @@ +// +// FieldInputValueTests+BoolCase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldInputValueTests { + @Suite("Bool Case") + internal struct BoolCase { + @Test("Bool case with true converts to string 'true'") + internal func boolCaseWithTrueConvertsToStringTrue() throws { + let input = FieldInputValue.bool(true) + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "true") + } + + @Test("Bool case with false converts to string 'false'") + internal func boolCaseWithFalseConvertsToStringFalse() throws { + let input = FieldInputValue.bool(false) + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "false") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+DoubleCase.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+DoubleCase.swift new file mode 100644 index 00000000..0ac19da6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+DoubleCase.swift @@ -0,0 +1,93 @@ +// +// FieldInputValueTests+DoubleCase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldInputValueTests { + @Suite("Double Case") + internal struct DoubleCase { + @Test("Double case converts to double type") + internal func doubleCaseConvertsToDoubleType() throws { + let input = FieldInputValue.double(19.99) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + #expect(value == "19.99") + } + + @Test("Double case with zero") + internal func doubleCaseWithZero() throws { + let input = FieldInputValue.double(0.0) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + #expect(value == "0.0") + } + + @Test("Double case with negative number") + internal func doubleCaseWithNegativeNumber() throws { + let input = FieldInputValue.double(-3.14) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + #expect(value == "-3.14") + } + + @Test("Double case with integer value") + internal func doubleCaseWithIntegerValue() throws { + let input = FieldInputValue.double(42.0) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + #expect(value == "42.0") + } + + @Test("Double case with scientific notation") + internal func doubleCaseWithScientificNotation() throws { + let input = FieldInputValue.double(1.5e10) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + // Value may be in scientific notation + #expect(value.contains("e") || value.contains("E") || value == "15000000000.0") + } + + @Test("Double case with very small number") + internal func doubleCaseWithVerySmallNumber() throws { + let input = FieldInputValue.double(0.00001) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + #expect(value.contains("0.00001") || value.contains("e")) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+EdgeCases.swift new file mode 100644 index 00000000..77c9ddff --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+EdgeCases.swift @@ -0,0 +1,86 @@ +// +// FieldInputValueTests+EdgeCases.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldInputValueTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test("String case preserves whitespace") + internal func stringCasePreservesWhitespace() throws { + let input = FieldInputValue.string(" spaces ") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == " spaces ") + } + + @Test("String case with newlines") + internal func stringCaseWithNewlines() throws { + let input = FieldInputValue.string("line1\nline2") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "line1\nline2") + } + + @Test("String case with tabs") + internal func stringCaseWithTabs() throws { + let input = FieldInputValue.string("col1\tcol2") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "col1\tcol2") + } + + @Test("Double case preserves precision") + internal func doubleCasePreservesPrecision() throws { + let input = FieldInputValue.double(3.141592653589793) + let (type, value) = try input.toFieldComponents() + + #expect(type == .double) + // String should contain most of the precision + #expect(value.contains("3.14")) + } + + @Test("Multiple conversions of same value produce consistent results") + internal func multipleConversionsProduceConsistentResults() throws { + let input = FieldInputValue.int(42) + + let (type1, value1) = try input.toFieldComponents() + let (type2, value2) = try input.toFieldComponents() + + #expect(type1 == type2) + #expect(value1 == value2) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+IntCase.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+IntCase.swift new file mode 100644 index 00000000..8e7762b7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+IntCase.swift @@ -0,0 +1,83 @@ +// +// FieldInputValueTests+IntCase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldInputValueTests { + @Suite("Int Case") + internal struct IntCase { + @Test("Int case converts to int64 type") + internal func intCaseConvertsToInt64Type() throws { + let input = FieldInputValue.int(42) + let (type, value) = try input.toFieldComponents() + + #expect(type == .int64) + #expect(value == "42") + } + + @Test("Int case with zero") + internal func intCaseWithZero() throws { + let input = FieldInputValue.int(0) + let (type, value) = try input.toFieldComponents() + + #expect(type == .int64) + #expect(value == "0") + } + + @Test("Int case with negative number") + internal func intCaseWithNegativeNumber() throws { + let input = FieldInputValue.int(-123) + let (type, value) = try input.toFieldComponents() + + #expect(type == .int64) + #expect(value == "-123") + } + + @Test("Int case with large positive number") + internal func intCaseWithLargePositiveNumber() throws { + let input = FieldInputValue.int(Int.max) + let (type, value) = try input.toFieldComponents() + + #expect(type == .int64) + #expect(value == String(Int.max)) + } + + @Test("Int case with large negative number") + internal func intCaseWithLargeNegativeNumber() throws { + let input = FieldInputValue.int(Int.min) + let (type, value) = try input.toFieldComponents() + + #expect(type == .int64) + #expect(value == String(Int.min)) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+StringCase.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+StringCase.swift new file mode 100644 index 00000000..79395093 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests+StringCase.swift @@ -0,0 +1,83 @@ +// +// FieldInputValueTests+StringCase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldInputValueTests { + @Suite("String Case") + internal struct StringCase { + @Test("String case converts to string type") + internal func stringCaseConvertsToStringType() throws { + let input = FieldInputValue.string("Hello World") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "Hello World") + } + + @Test("String case with empty string") + internal func stringCaseWithEmptyString() throws { + let input = FieldInputValue.string("") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value.isEmpty) + } + + @Test("String case with special characters") + internal func stringCaseWithSpecialCharacters() throws { + let input = FieldInputValue.string("!@#$%^&*()") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "!@#$%^&*()") + } + + @Test("String case with Unicode") + internal func stringCaseWithUnicode() throws { + let input = FieldInputValue.string("こんにちは") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "こんにちは") + } + + @Test("String case with emoji") + internal func stringCaseWithEmoji() throws { + let input = FieldInputValue.string("👍🎉") + let (type, value) = try input.toFieldComponents() + + #expect(type == .string) + #expect(value == "👍🎉") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests.swift new file mode 100644 index 00000000..a9a8b9a8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValue/FieldInputValueTests.swift @@ -0,0 +1,33 @@ +// +// FieldInputValueTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("FieldInputValue Conversion") +internal enum FieldInputValueTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValueTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValueTests.swift deleted file mode 100644 index bdd2d5df..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldInputValueTests.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// FieldInputValueTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("FieldInputValue Conversion Tests") -struct FieldInputValueTests { - - // MARK: - String Case Tests - - @Test("String case converts to string type") - func stringCaseConvertsToStringType() throws { - let input = FieldInputValue.string("Hello World") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "Hello World") - } - - @Test("String case with empty string") - func stringCaseWithEmptyString() throws { - let input = FieldInputValue.string("") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "") - } - - @Test("String case with special characters") - func stringCaseWithSpecialCharacters() throws { - let input = FieldInputValue.string("!@#$%^&*()") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "!@#$%^&*()") - } - - @Test("String case with Unicode") - func stringCaseWithUnicode() throws { - let input = FieldInputValue.string("こんにちは") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "こんにちは") - } - - @Test("String case with emoji") - func stringCaseWithEmoji() throws { - let input = FieldInputValue.string("👍🎉") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "👍🎉") - } - - // MARK: - Int Case Tests - - @Test("Int case converts to int64 type") - func intCaseConvertsToInt64Type() throws { - let input = FieldInputValue.int(42) - let (type, value) = try input.toFieldComponents() - - #expect(type == .int64) - #expect(value == "42") - } - - @Test("Int case with zero") - func intCaseWithZero() throws { - let input = FieldInputValue.int(0) - let (type, value) = try input.toFieldComponents() - - #expect(type == .int64) - #expect(value == "0") - } - - @Test("Int case with negative number") - func intCaseWithNegativeNumber() throws { - let input = FieldInputValue.int(-123) - let (type, value) = try input.toFieldComponents() - - #expect(type == .int64) - #expect(value == "-123") - } - - @Test("Int case with large positive number") - func intCaseWithLargePositiveNumber() throws { - let input = FieldInputValue.int(Int.max) - let (type, value) = try input.toFieldComponents() - - #expect(type == .int64) - #expect(value == String(Int.max)) - } - - @Test("Int case with large negative number") - func intCaseWithLargeNegativeNumber() throws { - let input = FieldInputValue.int(Int.min) - let (type, value) = try input.toFieldComponents() - - #expect(type == .int64) - #expect(value == String(Int.min)) - } - - // MARK: - Double Case Tests - - @Test("Double case converts to double type") - func doubleCaseConvertsToDoubleType() throws { - let input = FieldInputValue.double(19.99) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - #expect(value == "19.99") - } - - @Test("Double case with zero") - func doubleCaseWithZero() throws { - let input = FieldInputValue.double(0.0) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - #expect(value == "0.0") - } - - @Test("Double case with negative number") - func doubleCaseWithNegativeNumber() throws { - let input = FieldInputValue.double(-3.14) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - #expect(value == "-3.14") - } - - @Test("Double case with integer value") - func doubleCaseWithIntegerValue() throws { - let input = FieldInputValue.double(42.0) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - #expect(value == "42.0") - } - - @Test("Double case with scientific notation") - func doubleCaseWithScientificNotation() throws { - let input = FieldInputValue.double(1.5e10) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - // Value may be in scientific notation - #expect(value.contains("e") || value.contains("E") || value == "15000000000.0") - } - - @Test("Double case with very small number") - func doubleCaseWithVerySmallNumber() throws { - let input = FieldInputValue.double(0.00001) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - #expect(value.contains("0.00001") || value.contains("e")) - } - - // MARK: - Bool Case Tests - - @Test("Bool case with true converts to string 'true'") - func boolCaseWithTrueConvertsToStringTrue() throws { - let input = FieldInputValue.bool(true) - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "true") - } - - @Test("Bool case with false converts to string 'false'") - func boolCaseWithFalseConvertsToStringFalse() throws { - let input = FieldInputValue.bool(false) - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "false") - } - - // MARK: - Edge Case Tests - - @Test("String case preserves whitespace") - func stringCasePreservesWhitespace() throws { - let input = FieldInputValue.string(" spaces ") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == " spaces ") - } - - @Test("String case with newlines") - func stringCaseWithNewlines() throws { - let input = FieldInputValue.string("line1\nline2") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "line1\nline2") - } - - @Test("String case with tabs") - func stringCaseWithTabs() throws { - let input = FieldInputValue.string("col1\tcol2") - let (type, value) = try input.toFieldComponents() - - #expect(type == .string) - #expect(value == "col1\tcol2") - } - - @Test("Double case preserves precision") - func doubleCasePreservesPrecision() throws { - let input = FieldInputValue.double(3.141592653589793) - let (type, value) = try input.toFieldComponents() - - #expect(type == .double) - // String should contain most of the precision - #expect(value.contains("3.14")) - } - - @Test("Multiple conversions of same value produce consistent results") - func multipleConversionsProduceConsistentResults() throws { - let input = FieldInputValue.int(42) - - let (type1, value1) = try input.toFieldComponents() - let (type2, value2) = try input.toFieldComponents() - - #expect(type1 == type2) - #expect(value1 == value2) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+BooleanFieldDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+BooleanFieldDecoding.swift new file mode 100644 index 00000000..66c4f6ed --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+BooleanFieldDecoding.swift @@ -0,0 +1,72 @@ +// +// FieldsInputTests+BooleanFieldDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Boolean Field Decoding") + internal struct BooleanFieldDecoding { + @Test("Decode true boolean field") + internal func decodeTrueBoolField() throws { + let json = """ + { + "isActive": true + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "isActive") + #expect(fields[0].type == .string) + #expect(fields[0].value == "true") + } + + @Test("Decode false boolean field") + internal func decodeFalseBoolField() throws { + let json = """ + { + "isEnabled": false + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "isEnabled") + #expect(fields[0].type == .string) + #expect(fields[0].value == "false") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+DoubleFieldDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+DoubleFieldDecoding.swift new file mode 100644 index 00000000..43d7dbb4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+DoubleFieldDecoding.swift @@ -0,0 +1,72 @@ +// +// FieldsInputTests+DoubleFieldDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Double Field Decoding") + internal struct DoubleFieldDecoding { + @Test("Decode double field") + internal func decodeDoubleField() throws { + let json = """ + { + "price": 19.99 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "price") + #expect(fields[0].type == .double) + #expect(fields[0].value == "19.99") + } + + @Test("Decode negative double field") + internal func decodeNegativeDoubleField() throws { + let json = """ + { + "latitude": -33.8688 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "latitude") + #expect(fields[0].type == .double) + #expect(fields[0].value == "-33.8688") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+Encoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+Encoding.swift new file mode 100644 index 00000000..fde65ea8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+Encoding.swift @@ -0,0 +1,96 @@ +// +// FieldsInputTests+Encoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Encoding") + internal struct Encoding { + @Test("Encode and decode string field") + internal func encodeDecodeStringField() throws { + let json = """ + { + "name": "Test" + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + + let encoded = try JSONEncoder().encode(fieldsInput) + let decoded = try JSONDecoder().decode(FieldsInput.self, from: encoded) + let fields = try decoded.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "name") + #expect(fields[0].value == "Test") + } + + @Test("Encode and decode integer field") + internal func encodeDecodeIntField() throws { + let json = """ + { + "count": 100 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + + let encoded = try JSONEncoder().encode(fieldsInput) + let decoded = try JSONDecoder().decode(FieldsInput.self, from: encoded) + let fields = try decoded.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "count") + #expect(fields[0].type == .int64) + #expect(fields[0].value == "100") + } + + @Test("Encode and decode multiple fields") + internal func encodeDecodeMultipleFields() throws { + let json = """ + { + "title": "Item", + "quantity": 3, + "price": 15.50 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + + let encoded = try JSONEncoder().encode(fieldsInput) + let decoded = try JSONDecoder().decode(FieldsInput.self, from: encoded) + let fields = try decoded.toFields() + + #expect(fields.count == 3) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+FieldName.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+FieldName.swift new file mode 100644 index 00000000..0c4a8fa8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+FieldName.swift @@ -0,0 +1,68 @@ +// +// FieldsInputTests+FieldName.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Field Name") + internal struct FieldName { + @Test("Decode field with underscore in name") + internal func decodeFieldWithUnderscore() throws { + let json = """ + { + "field_name": "value" + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "field_name") + } + + @Test("Decode field with camelCase name") + internal func decodeFieldWithCamelCase() throws { + let json = """ + { + "firstName": "John" + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "firstName") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+IntegerFieldDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+IntegerFieldDecoding.swift new file mode 100644 index 00000000..c582c7d3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+IntegerFieldDecoding.swift @@ -0,0 +1,89 @@ +// +// FieldsInputTests+IntegerFieldDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Integer Field Decoding") + internal struct IntegerFieldDecoding { + @Test("Decode integer field") + internal func decodeIntField() throws { + let json = """ + { + "count": 42 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "count") + #expect(fields[0].type == .int64) + #expect(fields[0].value == "42") + } + + @Test("Decode negative integer field") + internal func decodeNegativeIntField() throws { + let json = """ + { + "temperature": -10 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "temperature") + #expect(fields[0].type == .int64) + #expect(fields[0].value == "-10") + } + + @Test("Decode zero integer field") + internal func decodeZeroIntField() throws { + let json = """ + { + "balance": 0 + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "balance") + #expect(fields[0].type == .int64) + #expect(fields[0].value == "0") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+MultipleFields.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+MultipleFields.swift new file mode 100644 index 00000000..817d1a94 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+MultipleFields.swift @@ -0,0 +1,79 @@ +// +// FieldsInputTests+MultipleFields.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Multiple Fields") + internal struct MultipleFields { + @Test("Decode multiple mixed type fields") + internal func decodeMultipleFields() throws { + let json = """ + { + "title": "Test Item", + "count": 5, + "price": 9.99, + "active": true + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 4) + + let fieldsByName = Dictionary(uniqueKeysWithValues: fields.map { ($0.name, $0) }) + + #expect(fieldsByName["title"]?.type == .string) + #expect(fieldsByName["title"]?.value == "Test Item") + + #expect(fieldsByName["count"]?.type == .int64) + #expect(fieldsByName["count"]?.value == "5") + + #expect(fieldsByName["price"]?.type == .double) + #expect(fieldsByName["price"]?.value == "9.99") + + #expect(fieldsByName["active"]?.type == .string) + #expect(fieldsByName["active"]?.value == "true") + } + + @Test("Decode empty object") + internal func decodeEmptyObject() throws { + let json = "{}" + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.isEmpty) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+SpecialValue.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+SpecialValue.swift new file mode 100644 index 00000000..7a8bafe4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+SpecialValue.swift @@ -0,0 +1,68 @@ +// +// FieldsInputTests+SpecialValue.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("Special Value") + internal struct SpecialValue { + @Test("Decode field with whitespace in string value") + internal func decodeFieldWithWhitespace() throws { + let json = """ + { + "description": " spaced text " + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].value == " spaced text ") + } + + @Test("Decode field with unicode characters") + internal func decodeFieldWithUnicode() throws { + let json = """ + { + "emoji": "🎉" + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].value == "🎉") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+StringFieldDecoding.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+StringFieldDecoding.swift new file mode 100644 index 00000000..2375611b --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests+StringFieldDecoding.swift @@ -0,0 +1,72 @@ +// +// FieldsInputTests+StringFieldDecoding.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension FieldsInputTests { + @Suite("String Field Decoding") + internal struct StringFieldDecoding { + @Test("Decode string field") + internal func decodeStringField() throws { + let json = """ + { + "title": "Hello World" + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "title") + #expect(fields[0].type == .string) + #expect(fields[0].value == "Hello World") + } + + @Test("Decode empty string field") + internal func decodeEmptyStringField() throws { + let json = """ + { + "description": "" + } + """ + let data = Data(json.utf8) + let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) + let fields = try fieldsInput.toFields() + + #expect(fields.count == 1) + #expect(fields[0].name == "description") + #expect(fields[0].type == .string) + #expect(fields[0].value.isEmpty) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests.swift new file mode 100644 index 00000000..4a17f5b9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInput/FieldsInputTests.swift @@ -0,0 +1,33 @@ +// +// FieldsInputTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("FieldsInput") +internal enum FieldsInputTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInputTests.swift deleted file mode 100644 index f94e05eb..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Types/FieldsInputTests.swift +++ /dev/null @@ -1,364 +0,0 @@ -// -// FieldsInputTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("FieldsInput Tests") -struct FieldsInputTests { - - // MARK: - String Field Decoding Tests - - @Test("Decode string field") - func decodeStringField() throws { - let json = """ - { - "title": "Hello World" - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "title") - #expect(fields[0].type == .string) - #expect(fields[0].value == "Hello World") - } - - @Test("Decode empty string field") - func decodeEmptyStringField() throws { - let json = """ - { - "description": "" - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "description") - #expect(fields[0].type == .string) - #expect(fields[0].value == "") - } - - // MARK: - Integer Field Decoding Tests - - @Test("Decode integer field") - func decodeIntField() throws { - let json = """ - { - "count": 42 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "count") - #expect(fields[0].type == .int64) - #expect(fields[0].value == "42") - } - - @Test("Decode negative integer field") - func decodeNegativeIntField() throws { - let json = """ - { - "temperature": -10 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "temperature") - #expect(fields[0].type == .int64) - #expect(fields[0].value == "-10") - } - - @Test("Decode zero integer field") - func decodeZeroIntField() throws { - let json = """ - { - "balance": 0 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "balance") - #expect(fields[0].type == .int64) - #expect(fields[0].value == "0") - } - - // MARK: - Double Field Decoding Tests - - @Test("Decode double field") - func decodeDoubleField() throws { - let json = """ - { - "price": 19.99 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "price") - #expect(fields[0].type == .double) - #expect(fields[0].value == "19.99") - } - - @Test("Decode negative double field") - func decodeNegativeDoubleField() throws { - let json = """ - { - "latitude": -33.8688 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "latitude") - #expect(fields[0].type == .double) - #expect(fields[0].value == "-33.8688") - } - - // MARK: - Boolean Field Decoding Tests - - @Test("Decode true boolean field") - func decodeTrueBoolField() throws { - let json = """ - { - "isActive": true - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "isActive") - #expect(fields[0].type == .string) - #expect(fields[0].value == "true") - } - - @Test("Decode false boolean field") - func decodeFalseBoolField() throws { - let json = """ - { - "isEnabled": false - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "isEnabled") - #expect(fields[0].type == .string) - #expect(fields[0].value == "false") - } - - // MARK: - Multiple Fields Tests - - @Test("Decode multiple mixed type fields") - func decodeMultipleFields() throws { - let json = """ - { - "title": "Test Item", - "count": 5, - "price": 9.99, - "active": true - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 4) - - let fieldsByName = Dictionary(uniqueKeysWithValues: fields.map { ($0.name, $0) }) - - #expect(fieldsByName["title"]?.type == .string) - #expect(fieldsByName["title"]?.value == "Test Item") - - #expect(fieldsByName["count"]?.type == .int64) - #expect(fieldsByName["count"]?.value == "5") - - #expect(fieldsByName["price"]?.type == .double) - #expect(fieldsByName["price"]?.value == "9.99") - - #expect(fieldsByName["active"]?.type == .string) - #expect(fieldsByName["active"]?.value == "true") - } - - @Test("Decode empty object") - func decodeEmptyObject() throws { - let json = "{}" - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.isEmpty) - } - - // MARK: - Encoding Tests - - @Test("Encode and decode string field") - func encodeDecodeStringField() throws { - let json = """ - { - "name": "Test" - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - - let encoded = try JSONEncoder().encode(fieldsInput) - let decoded = try JSONDecoder().decode(FieldsInput.self, from: encoded) - let fields = try decoded.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "name") - #expect(fields[0].value == "Test") - } - - @Test("Encode and decode integer field") - func encodeDecodeIntField() throws { - let json = """ - { - "count": 100 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - - let encoded = try JSONEncoder().encode(fieldsInput) - let decoded = try JSONDecoder().decode(FieldsInput.self, from: encoded) - let fields = try decoded.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "count") - #expect(fields[0].type == .int64) - #expect(fields[0].value == "100") - } - - @Test("Encode and decode multiple fields") - func encodeDecodeMultipleFields() throws { - let json = """ - { - "title": "Item", - "quantity": 3, - "price": 15.50 - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - - let encoded = try JSONEncoder().encode(fieldsInput) - let decoded = try JSONDecoder().decode(FieldsInput.self, from: encoded) - let fields = try decoded.toFields() - - #expect(fields.count == 3) - } - - // MARK: - Field Name Tests - - @Test("Decode field with underscore in name") - func decodeFieldWithUnderscore() throws { - let json = """ - { - "field_name": "value" - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "field_name") - } - - @Test("Decode field with camelCase name") - func decodeFieldWithCamelCase() throws { - let json = """ - { - "firstName": "John" - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].name == "firstName") - } - - // MARK: - Special Value Tests - - @Test("Decode field with whitespace in string value") - func decodeFieldWithWhitespace() throws { - let json = """ - { - "description": " spaced text " - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].value == " spaced text ") - } - - @Test("Decode field with unicode characters") - func decodeFieldWithUnicode() throws { - let json = """ - { - "emoji": "🎉" - } - """ - let data = Data(json.utf8) - let fieldsInput = try JSONDecoder().decode(FieldsInput.self, from: data) - let fields = try fieldsInput.toFields() - - #expect(fields.count == 1) - #expect(fields[0].value == "🎉") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/TestHelpers/UserInfoTestExtension.swift b/Examples/MistDemo/Tests/MistDemoTests/UserInfoTestExtension.swift similarity index 97% rename from Examples/MistDemo/Tests/MistDemoTests/TestHelpers/UserInfoTestExtension.swift rename to Examples/MistDemo/Tests/MistDemoTests/UserInfoTestExtension.swift index 5aaf0223..f04be53c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/TestHelpers/UserInfoTestExtension.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/UserInfoTestExtension.swift @@ -28,6 +28,7 @@ // import Foundation +internal import MistKitOpenAPI @testable import MistKit @@ -44,7 +45,7 @@ extension UserInfo { /// - lastName: The user's last name /// - emailAddress: The user's email address /// - Returns: A UserInfo instance for testing - static func test( + internal static func test( userRecordName: String, firstName: String? = nil, lastName: String? = nil, diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncChannelTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncChannelTests.swift deleted file mode 100644 index ef219dc9..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncChannelTests.swift +++ /dev/null @@ -1,280 +0,0 @@ -// -// AsyncChannelTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("AsyncChannel Tests") -struct AsyncChannelTests { - - // MARK: - Basic Send/Receive Tests - - @Test("Send then receive a value") - func sendThenReceive() async { - let channel = AsyncChannel() - - await channel.send("test") - let received = await channel.receive() - - #expect(received == "test") - } - - @Test("Receive waits for send") - func receiveWaitsForSend() async { - let channel = AsyncChannel() - - Task { - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - await channel.send(42) - } - - let received = await channel.receive() - #expect(received == 42) - } - - @Test("Send stores value for later receive") - func sendStoresValue() async { - let channel = AsyncChannel() - - await channel.send("stored") - - // Delay before receiving - try? await Task.sleep(nanoseconds: 50_000_000) // 50ms - - let received = await channel.receive() - #expect(received == "stored") - } - - // MARK: - Multiple Operations Tests - - @Test("Multiple send and receive operations") - func multipleSendReceive() async { - let channel = AsyncChannel() - - await channel.send(1) - let first = await channel.receive() - #expect(first == 1) - - await channel.send(2) - let second = await channel.receive() - #expect(second == 2) - - await channel.send(3) - let third = await channel.receive() - #expect(third == 3) - } - - @Test("Sequential receive operations") - func sequentialReceives() async { - let channel = AsyncChannel() - - Task { - try? await Task.sleep(nanoseconds: 50_000_000) - await channel.send("first") - } - - Task { - try? await Task.sleep(nanoseconds: 100_000_000) - await channel.send("second") - } - - let first = await channel.receive() - #expect(first == "first") - - let second = await channel.receive() - #expect(second == "second") - } - - // MARK: - Concurrent Access Tests - - @Test("Concurrent sends are handled correctly") - func concurrentSends() async { - let channel = AsyncChannel() - - await withTaskGroup(of: Void.self) { group in - group.addTask { - await channel.send(1) - } - group.addTask { - await channel.send(2) - } - group.addTask { - await channel.send(3) - } - } - - // All values were sent, now receive them - let first = await channel.receive() - #expect([1, 2, 3].contains(first)) - } - - @Test("Sequential receives from concurrent senders") - func sequentialReceivesFromTasks() async { - let channel = AsyncChannel() - - Task { await channel.send("value1") } - let result1 = await channel.receive() - - Task { await channel.send("value2") } - let result2 = await channel.receive() - - #expect(result1 == "value1") - #expect(result2 == "value2") - } - - // MARK: - Type Tests - - @Test("Channel works with String type") - func stringChannel() async { - let channel = AsyncChannel() - await channel.send("hello") - let received = await channel.receive() - #expect(received == "hello") - } - - @Test("Channel works with Int type") - func intChannel() async { - let channel = AsyncChannel() - await channel.send(100) - let received = await channel.receive() - #expect(received == 100) - } - - @Test("Channel works with custom Sendable type") - func customTypeChannel() async { - struct TestData: Sendable, Equatable { - let id: Int - let name: String - } - - let channel = AsyncChannel() - let data = TestData(id: 1, name: "test") - - await channel.send(data) - let received = await channel.receive() - - #expect(received == data) - } - - @Test("Channel works with optional type") - func optionalChannel() async { - let channel = AsyncChannel() - - await channel.send(nil) - let received = await channel.receive() - #expect(received == nil) - - await channel.send("value") - let received2 = await channel.receive() - #expect(received2 == "value") - } - - // MARK: - Behavior Tests - - @Test("Channel clears value after receive") - func channelClearsValue() async { - let channel = AsyncChannel() - - await channel.send(1) - _ = await channel.receive() - - // Next receive should wait for new send - Task { - try? await Task.sleep(nanoseconds: 50_000_000) - await channel.send(2) - } - - let received = await channel.receive() - #expect(received == 2) - } - - @Test("Send replaces continuation with value delivery") - func sendResumesContinuation() async { - let channel = AsyncChannel() - - let receiveTask = Task { - await channel.receive() - } - - try? await Task.sleep(nanoseconds: 50_000_000) - await channel.send(true) - - let received = await receiveTask.value - #expect(received == true) - } - - // MARK: - Actor Isolation Tests - - @Test("Channel is isolated actor") - func channelIsActor() async { - let channel = AsyncChannel() - - // Verifies AsyncChannel is an actor: concurrent sends don't data-race. - // Only the last write survives since the channel buffers one value. - await withTaskGroup(of: Void.self) { group in - for i in 0..<10 { - group.addTask { - await channel.send("message-\(i)") - } - } - } - - let received = await channel.receive() - #expect(received.hasPrefix("message-")) - } - - @Test("Multiple channels are independent") - func multipleChannelsIndependent() async { - let channel1 = AsyncChannel() - let channel2 = AsyncChannel() - - await channel1.send(1) - await channel2.send(2) - - let received1 = await channel1.receive() - let received2 = await channel2.receive() - - #expect(received1 == 1) - #expect(received2 == 2) - } - - // MARK: - Performance Tests - - @Test("Channel handles rapid send/receive") - func rapidSendReceive() async { - let channel = AsyncChannel() - - for i in 0..<100 { - await channel.send(i) - let received = await channel.receive() - #expect(received == i) - } - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+AsyncTimeoutError.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+AsyncTimeoutError.swift new file mode 100644 index 00000000..b41e5bb9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+AsyncTimeoutError.swift @@ -0,0 +1,64 @@ +// +// AsyncHelpersTests+AsyncTimeoutError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AsyncHelpersTests { + @Suite("AsyncTimeoutError") + internal struct AsyncTimeoutErrorTests { + @Test("AsyncTimeoutError timeout case has description") + internal func timeoutErrorDescription() { + let error = AsyncTimeoutError.timeout("Operation took too long") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Operation timed out") == true) + #expect(description?.contains("Operation took too long") == true) + } + + @Test("AsyncTimeoutError cancelled case has description") + internal func cancelledErrorDescription() { + let error = AsyncTimeoutError.cancelled("User interrupted") + let description = error.errorDescription + + #expect(description != nil) + #expect(description?.contains("Operation cancelled") == true) + #expect(description?.contains("User interrupted") == true) + } + + @Test("AsyncTimeoutError conforms to LocalizedError") + internal func timeoutErrorIsLocalizedError() { + let error: any Error = AsyncTimeoutError.timeout("test") + #expect(error is any LocalizedError) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+ConcurrentTimeout.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+ConcurrentTimeout.swift new file mode 100644 index 00000000..c54a7742 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+ConcurrentTimeout.swift @@ -0,0 +1,101 @@ +// +// AsyncHelpersTests+ConcurrentTimeout.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AsyncHelpersTests { + @Suite("Concurrent Timeout") + internal struct ConcurrentTimeout { + @Test( + "withTimeout cancels other tasks in group", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't fire the timeout race against an inner Task.sleep" + ) + ) + internal func cancelsOtherTasks() async throws { + // Intermittent on simulator cooperative executors (watchOS in particular): + // the operation's single long Task.sleep can complete before the polling + // timeout's many short sleeps detect the deadline — same root cause as + // the wasm32 gate above and the throwsOnTimeout / returnsAsyncValue + // tests in AsyncHelpersTests+Timeout.swift. + await withKnownIssue(isIntermittent: true) { + await #expect(throws: AsyncTimeoutError.self) { + try await withTimeout(seconds: 0.1) { + try await Task.sleep(nanoseconds: 500_000_000) + return "done" + } + } + } + } + + @Test( + "Multiple concurrent withTimeout operations", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't fire the timeout race against an inner Task.sleep" + ) + ) + internal func multipleConcurrentTimeouts() async throws { + await withTaskGroup(of: Void.self) { group in + group.addTask { + do { + _ = try await withTimeout(seconds: 1.0) { + "fast" + } + } catch { + Issue.record("Fast operation should not timeout") + } + } + + group.addTask { + // Intermittent on watchOS simulator cooperative executor — same root + // cause as `cancelsOtherTasks` above: a single long Task.sleep can win + // the race against the polling timeout's short sleeps. + await withKnownIssue(isIntermittent: true) { + do { + _ = try await withTimeout(seconds: 0.2) { + try await Task.sleep(nanoseconds: 2_000_000_000) + return "slow" + } + Issue.record("Slow operation should timeout") + } catch is AsyncTimeoutError { + // Expected + } catch { + Issue.record("Unexpected error type") + } + } + } + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+EdgeCases.swift new file mode 100644 index 00000000..af54aac9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+EdgeCases.swift @@ -0,0 +1,63 @@ +// +// AsyncHelpersTests+EdgeCases.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AsyncHelpersTests { + @Suite("Edge Cases") + internal struct EdgeCases { + @Test( + "withTimeout with short timeout throws", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't fire the timeout race against an inner Task.sleep" + ) + ) + internal func zeroTimeout() async { + await #expect(throws: AsyncTimeoutError.self) { + try await withTimeout(seconds: 0.001) { + try await Task.sleep(nanoseconds: 1_000_000_000) // 1s + return "should not return" + } + } + } + + @Test("withTimeout with immediate return") + internal func immediateReturn() async throws { + let result = try await withTimeout(seconds: 0.1) { + "immediate" + } + + #expect(result == "immediate") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+FormatTimeout.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+FormatTimeout.swift new file mode 100644 index 00000000..3ec35b3a --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+FormatTimeout.swift @@ -0,0 +1,67 @@ +// +// AsyncHelpersTests+FormatTimeout.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AsyncHelpersTests { + @Suite("Format Timeout") + internal struct FormatTimeout { + @Test("formatTimeout with seconds") + internal func formatSecondsTimeout() { + #expect(formatTimeout(30) == "30 seconds") + #expect(formatTimeout(45) == "45 seconds") + } + + @Test("formatTimeout with single minute") + internal func formatSingleMinute() { + #expect(formatTimeout(60) == "1 minute") + } + + @Test("formatTimeout with multiple minutes") + internal func formatMultipleMinutes() { + #expect(formatTimeout(120) == "2 minutes") + #expect(formatTimeout(300) == "5 minutes") + } + + @Test("formatTimeout with fractional seconds under 60") + internal func formatFractionalSeconds() { + #expect(formatTimeout(15.5) == "15 seconds") + #expect(formatTimeout(59.9) == "59 seconds") + } + + @Test("formatTimeout with fractional minutes") + internal func formatFractionalMinutes() { + #expect(formatTimeout(90) == "1 minute") + #expect(formatTimeout(150) == "2 minutes") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+Timeout.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+Timeout.swift new file mode 100644 index 00000000..86e8f5bc --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests+Timeout.swift @@ -0,0 +1,126 @@ +// +// AsyncHelpersTests+Timeout.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension AsyncHelpersTests { + @Suite("Timeout") + internal struct Timeout { + @Test("withTimeout completes before timeout") + internal func completesBeforeTimeout() async throws { + let result = try await withTimeout(seconds: 1.0) { + "success" + } + + #expect(result == "success") + } + + @Test( + "withTimeout throws on timeout", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't fire the timeout race against an inner Task.sleep" + ) + ) + internal func throwsOnTimeout() async { + // Intermittent: simulator cooperative executors (notably watchOS) can let the + // operation's single long Task.sleep complete before the polling timeout task's + // many short sleeps detect the deadline — same root cause as the wasm32 gate. + await withKnownIssue(isIntermittent: true) { + await #expect(throws: AsyncTimeoutError.self) { + try await withTimeout(seconds: 0.1) { + try await Task.sleep(nanoseconds: 500_000_000) // 500ms + return "too slow" + } + } + } + } + + @Test( + "withTimeout returns value from async operation", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor's Task.sleep is unreliable; operation's inner sleep can be starved" + ) + ) + internal func returnsAsyncValue() async { + // The 30 s budget (vs. the operation's 50 ms inner sleep) is intentionally + // generous: under iOS-simulator CI load the operation task's single long + // Task.sleep can be scheduled behind the polling timeout task's many short + // sleeps, so a tighter budget produced flaky timeouts (#283). + // Even at 30s the iOS simulator under heavy CI load can exceed the budget + // (observed wall times of 48-50s for ostensibly trivial operations), so + // mark as intermittent rather than chasing the budget upward indefinitely. + await withKnownIssue(isIntermittent: true) { + let result = try await withTimeout(seconds: 30.0) { + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + return 42 + } + + #expect(result == 42) + } + } + + @Test("withTimeout propagates operation errors") + internal func propagatesErrors() async { + struct TestError: Error {} + + await #expect(throws: TestError.self) { + try await withTimeout(seconds: 1.0) { + throw TestError() + } + } + } + + @Test( + "withTimeout with very short timeout", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't time-slice the timeout race like Darwin/Linux dispatch" + ) + ) + internal func veryShortTimeout() async { + // Same root cause as `throwsOnTimeout` / `returnsAsyncValue`: under + // simulator load (observed on visionOS, run #25990091951) the + // operation's single 100ms Task.sleep can finish before the polling + // timeout task's many short sleeps detect the 1ms deadline. + await withKnownIssue(isIntermittent: true) { + await #expect(throws: AsyncTimeoutError.self) { + try await withTimeout(seconds: 0.001) { + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + return "unreachable" + } + } + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests.swift new file mode 100644 index 00000000..1d4d2782 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpers/AsyncHelpersTests.swift @@ -0,0 +1,33 @@ +// +// AsyncHelpersTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("AsyncHelpers") +internal enum AsyncHelpersTests {} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpersTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpersTests.swift deleted file mode 100644 index 039bd769..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AsyncHelpersTests.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// AsyncHelpersTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo - -@Suite("AsyncHelpers Tests") -struct AsyncHelpersTests { - - // MARK: - Timeout Tests - - @Test("withTimeout completes before timeout") - func completesBeforeTimeout() async throws { - let result = try await withTimeout(seconds: 1.0) { - return "success" - } - - #expect(result == "success") - } - - @Test("withTimeout throws on timeout") - func throwsOnTimeout() async { - await #expect(throws: AsyncTimeoutError.self) { - try await withTimeout(seconds: 0.1) { - try await Task.sleep(nanoseconds: 500_000_000) // 500ms - return "too slow" - } - } - } - - @Test("withTimeout returns value from async operation") - func returnsAsyncValue() async throws { - let result = try await withTimeout(seconds: 1.0) { - try await Task.sleep(nanoseconds: 50_000_000) // 50ms - return 42 - } - - #expect(result == 42) - } - - @Test("withTimeout propagates operation errors") - func propagatesErrors() async { - struct TestError: Error {} - - await #expect(throws: TestError.self) { - try await withTimeout(seconds: 1.0) { - throw TestError() - } - } - } - - @Test("withTimeout with very short timeout") - func veryShortTimeout() async { - await #expect(throws: AsyncTimeoutError.self) { - try await withTimeout(seconds: 0.001) { - try await Task.sleep(nanoseconds: 100_000_000) // 100ms - return "unreachable" - } - } - } - - // MARK: - Format Timeout Tests - - @Test("formatTimeout with seconds") - func formatSecondsTimeout() { - #expect(formatTimeout(30) == "30 seconds") - #expect(formatTimeout(45) == "45 seconds") - } - - @Test("formatTimeout with single minute") - func formatSingleMinute() { - #expect(formatTimeout(60) == "1 minute") - } - - @Test("formatTimeout with multiple minutes") - func formatMultipleMinutes() { - #expect(formatTimeout(120) == "2 minutes") - #expect(formatTimeout(300) == "5 minutes") - } - - @Test("formatTimeout with fractional seconds under 60") - func formatFractionalSeconds() { - #expect(formatTimeout(15.5) == "15 seconds") - #expect(formatTimeout(59.9) == "59 seconds") - } - - @Test("formatTimeout with fractional minutes") - func formatFractionalMinutes() { - #expect(formatTimeout(90) == "1 minute") - #expect(formatTimeout(150) == "2 minutes") - } - - // MARK: - AsyncTimeoutError Tests - - @Test("AsyncTimeoutError timeout case has description") - func timeoutErrorDescription() { - let error = AsyncTimeoutError.timeout("Operation took too long") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Operation timed out") == true) - #expect(description?.contains("Operation took too long") == true) - } - - @Test("AsyncTimeoutError cancelled case has description") - func cancelledErrorDescription() { - let error = AsyncTimeoutError.cancelled("User interrupted") - let description = error.errorDescription - - #expect(description != nil) - #expect(description?.contains("Operation cancelled") == true) - #expect(description?.contains("User interrupted") == true) - } - - @Test("AsyncTimeoutError conforms to LocalizedError") - func timeoutErrorIsLocalizedError() { - let error: any Error = AsyncTimeoutError.timeout("test") - #expect(error is LocalizedError) - } - - // MARK: - Concurrent Timeout Tests - - @Test("withTimeout cancels other tasks in group") - func cancelsOtherTasks() async throws { - await #expect(throws: AsyncTimeoutError.self) { - try await withTimeout(seconds: 0.1) { - try await Task.sleep(nanoseconds: 500_000_000) - return "done" - } - } - } - - @Test("Multiple concurrent withTimeout operations") - func multipleConcurrentTimeouts() async throws { - await withTaskGroup(of: Void.self) { group in - group.addTask { - do { - _ = try await withTimeout(seconds: 1.0) { - return "fast" - } - } catch { - Issue.record("Fast operation should not timeout") - } - } - - group.addTask { - do { - _ = try await withTimeout(seconds: 0.05) { - try await Task.sleep(nanoseconds: 200_000_000) - return "slow" - } - Issue.record("Slow operation should timeout") - } catch is AsyncTimeoutError { - // Expected - } catch { - Issue.record("Unexpected error type") - } - } - } - } - - // MARK: - Edge Cases - - @Test("withTimeout with short timeout throws") - func zeroTimeout() async { - await #expect(throws: AsyncTimeoutError.self) { - try await withTimeout(seconds: 0.001) { - try await Task.sleep(nanoseconds: 1_000_000_000) // 1s - return "should not return" - } - } - } - - @Test("withTimeout with immediate return") - func immediateReturn() async throws { - let result = try await withTimeout(seconds: 0.1) { - return "immediate" - } - - #expect(result == "immediate") - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift new file mode 100644 index 00000000..efc186be --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift @@ -0,0 +1,81 @@ +// +// AuthenticationHelperTests+APIOnlyAuthentication.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit +@testable import MistKit + +extension AuthenticationHelperTests { + @Suite("API-Only Authentication") + internal struct APIOnlyAuthentication { + @Test("API-only auth enforces public database") + internal func apiOnlyEnforcesPublicDatabase() async throws { + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: nil, + privateKey: nil, + privateKeyFile: nil, + databaseOverride: nil + ) + + #expect(result.database == .public(.prefers(.serverToServer))) + #expect(result.authMethod.contains("API-only")) + } catch AuthenticationError.invalidAPIToken { + // Expected with test token + } catch is TokenManagerError { + // Expected - MistKit validates token format before AuthenticationHelper wraps it + } + } + + @Test("API-only auth throws on private database request") + internal func apiOnlyThrowsOnPrivateDatabaseRequest() async throws { + do { + _ = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: nil, + privateKey: nil, + privateKeyFile: nil, + databaseOverride: .private + ) + Issue.record("Expected privateRequiresWebAuth error") + } catch let error as AuthenticationError { + if case .privateRequiresWebAuth = error { + // Expected error - test passes + } else { + Issue.record("Expected privateRequiresWebAuth, got \(error)") + } + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift new file mode 100644 index 00000000..c2f5bc2d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift @@ -0,0 +1,82 @@ +// +// AuthenticationHelperTests+AuthenticationMethodPriority.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit +@testable import MistKit + +extension AuthenticationHelperTests { + @Suite("Authentication Method Priority") + internal struct AuthenticationMethodPriority { + @Test("Server-to-server takes precedence over web auth") + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal func serverToServerTakesPrecedence() async throws { + let privateKeyPEM = AuthenticationHelperTests.testPrivateKeyPEM + + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: "test-web-auth-token", // Should be ignored + keyID: "test-key-id", + privateKey: privateKeyPEM, + privateKeyFile: nil, + databaseOverride: nil + ) + + #expect(result.database == .public(.prefers(.serverToServer))) + #expect(result.authMethod.contains("Server-to-server")) + } catch AuthenticationError.invalidServerToServerCredentials { + // Expected with test credentials + } + } + + @Test("Web auth takes precedence over API-only") + internal func webAuthTakesPrecedence() async throws { + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: "test-web-auth-token", + keyID: nil, + privateKey: nil, + privateKeyFile: nil, + databaseOverride: nil + ) + + #expect(result.authMethod.contains("Web authentication")) + #expect(!result.authMethod.contains("API-only")) + } catch AuthenticationError.invalidWebAuthCredentials { + // Expected with test credentials + } catch is TokenManagerError { + // Expected - MistKit validates token format before AuthenticationHelper wraps it + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift new file mode 100644 index 00000000..89771f4f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift @@ -0,0 +1,174 @@ +// +// AuthenticationHelperTests+ServerToServerAuthentication.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit +@testable import MistKit + +extension AuthenticationHelperTests { + @Suite("Server-to-Server Authentication") + internal struct ServerToServerAuthentication { + @Test( + "Server-to-server auth with keyID creates ServerToServerAuthManager", + .enabled( + if: !TestPlatform.isWasm32, + "FileManager.temporaryDirectory write isn't supported under WASI sandbox" + ) + ) + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal func serverToServerAuthWithKeyID() async throws { + // Create a temporary private key file + let tempDir = FileManager.default.temporaryDirectory + let keyFile = tempDir.appendingPathComponent("test_key_\(UUID().uuidString).pem") + + // Use a test private key (this is a dummy key for testing only) + let privateKeyPEM = AuthenticationHelperTests.testPrivateKeyPEM + + try privateKeyPEM.write(to: keyFile, atomically: true, encoding: .utf8) + + defer { + try? FileManager.default.removeItem(at: keyFile) + } + + // Note: This will fail validation because it's a test key, but we can test the setup + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: "test-key-id", + privateKey: nil, + privateKeyFile: keyFile.path, + databaseOverride: nil + ) + + // If we get here, validation succeeded (unlikely with test key) + #expect(result.database == .public(.prefers(.serverToServer))) + #expect(result.authMethod.contains("Server-to-server")) + } catch AuthenticationError.invalidServerToServerCredentials { + // Expected - test key won't validate + // But we've confirmed the setup path works + } + } + + @Test("Server-to-server auth with inline private key") + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal func serverToServerAuthWithInlineKey() async throws { + let privateKeyPEM = AuthenticationHelperTests.testPrivateKeyPEM + + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: "test-key-id", + privateKey: privateKeyPEM, + privateKeyFile: nil, + databaseOverride: nil + ) + + #expect(result.database == .public(.prefers(.serverToServer))) + #expect(result.authMethod.contains("Server-to-server")) + } catch AuthenticationError.invalidServerToServerCredentials { + // Expected with test key + } + } + + @Test("Server-to-server auth enforces public database") + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal func serverToServerEnforcesPublicDatabase() async throws { + let privateKeyPEM = AuthenticationHelperTests.testPrivateKeyPEM + + // Attempt to override with private database should fail + do { + _ = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: "test-key-id", + privateKey: privateKeyPEM, + privateKeyFile: nil, + databaseOverride: .private + ) + Issue.record("Expected serverToServerRequiresPublicDatabase error") + } catch let error as AuthenticationError { + if case .serverToServerRequiresPublicDatabase = error { + // Expected error - test passes + } else { + Issue.record("Expected serverToServerRequiresPublicDatabase, got \(error)") + } + } + } + + @Test("Server-to-server auth throws on missing private key") + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal func serverToServerThrowsOnMissingPrivateKey() async throws { + do { + _ = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: "test-key-id", + privateKey: nil, + privateKeyFile: nil, + databaseOverride: nil + ) + Issue.record("Expected missingPrivateKey error") + } catch let error as AuthenticationError { + if case .missingPrivateKey = error { + // Expected error - test passes + } else { + Issue.record("Expected missingPrivateKey, got \(error)") + } + } + } + + @Test("Server-to-server auth throws on invalid key file path") + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal func serverToServerThrowsOnInvalidKeyFile() async throws { + let invalidPath = "/nonexistent/path/to/key.pem" + + do { + _ = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: nil, + keyID: "test-key-id", + privateKey: nil, + privateKeyFile: invalidPath, + databaseOverride: nil + ) + Issue.record("Should have thrown failedToReadPrivateKeyFile error") + } catch let error as AuthenticationError { + if case .failedToReadPrivateKeyFile(let path, _) = error { + #expect(path == invalidPath) + } else { + Issue.record("Expected failedToReadPrivateKeyFile, got \(error)") + } + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+TokenResolution.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+TokenResolution.swift new file mode 100644 index 00000000..01ac2457 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+TokenResolution.swift @@ -0,0 +1,87 @@ +// +// AuthenticationHelperTests+TokenResolution.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit +@testable import MistKit + +extension AuthenticationHelperTests { + @Suite("Token Resolution") + internal struct TokenResolution { + @Test("resolveAPIToken returns provided token when not empty", .mockEnvironment([:])) + internal func resolveAPITokenReturnsProvidedToken() { + let token = "my-api-token" + let resolved = AuthenticationHelper.resolveAPIToken( + token, environment: MockEnvironment.reader + ) + #expect(resolved == token) + } + + @Test( + "resolveAPIToken checks environment when empty", + .mockEnvironment(["CLOUDKIT_API_TOKEN": "env-api-token"]) + ) + internal func resolveAPITokenChecksEnvironment() { + let resolved = AuthenticationHelper.resolveAPIToken( + "", environment: MockEnvironment.reader + ) + #expect(resolved == "env-api-token") + } + + @Test("resolveWebAuthToken returns provided token when not empty", .mockEnvironment([:])) + internal func resolveWebAuthTokenReturnsProvidedToken() { + let token = "my-web-auth-token" + let resolved = AuthenticationHelper.resolveWebAuthToken( + token, environment: MockEnvironment.reader + ) + #expect(resolved == token) + } + + @Test("resolveWebAuthToken returns nil for empty string", .mockEnvironment([:])) + internal func resolveWebAuthTokenReturnsNilForEmpty() { + let resolved = AuthenticationHelper.resolveWebAuthToken( + "", environment: MockEnvironment.reader + ) + #expect(resolved == nil) + } + + @Test( + "resolveWebAuthToken checks environment variable", + .mockEnvironment(["CLOUDKIT_WEB_AUTH_TOKEN": "env-token"]) + ) + internal func resolveWebAuthTokenChecksEnvironment() { + let resolved = AuthenticationHelper.resolveWebAuthToken( + "", environment: MockEnvironment.reader + ) + #expect(resolved == "env-token") + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift new file mode 100644 index 00000000..1379cb67 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift @@ -0,0 +1,105 @@ +// +// AuthenticationHelperTests+WebAuthentication.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit +@testable import MistKit + +extension AuthenticationHelperTests { + @Suite("Web Authentication") + internal struct WebAuthentication { + @Test("Web auth defaults to private database") + internal func webAuthDefaultsToPrivateDatabase() async throws { + // Note: This will fail validation without real credentials + // We're testing the path selection logic + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: "test-web-auth-token", + keyID: nil, + privateKey: nil, + privateKeyFile: nil, + databaseOverride: nil + ) + + #expect(result.database == .private) + #expect(result.authMethod.contains("Web authentication")) + #expect(result.authMethod.contains("private")) + } catch AuthenticationError.invalidWebAuthCredentials { + // Expected with test credentials - but we know it chose the right path + } catch is TokenManagerError { + // Expected - MistKit validates token format before AuthenticationHelper wraps it + } + } + + @Test("Web auth allows public database override") + internal func webAuthAllowsPublicDatabaseOverride() async throws { + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: "test-web-auth-token", + keyID: nil, + privateKey: nil, + privateKeyFile: nil, + databaseOverride: .public(.prefers(.serverToServer)) + ) + + #expect(result.database == .public(.prefers(.serverToServer))) + #expect(result.authMethod.contains("Web authentication")) + #expect(result.authMethod.contains("public")) + } catch AuthenticationError.invalidWebAuthCredentials { + // Expected with test credentials + } catch is TokenManagerError { + // Expected - MistKit validates token format before AuthenticationHelper wraps it + } + } + + @Test("Web auth respects private database override") + internal func webAuthRespectsPrivateDatabaseOverride() async throws { + do { + let result = try await AuthenticationHelper.setupAuthentication( + apiToken: "test-api-token", + webAuthToken: "test-web-auth-token", + keyID: nil, + privateKey: nil, + privateKeyFile: nil, + databaseOverride: .private + ) + + #expect(result.database == .private) + } catch AuthenticationError.invalidWebAuthCredentials { + // Expected with test credentials + } catch is TokenManagerError { + // Expected - MistKit validates token format before AuthenticationHelper wraps it + } + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests.swift new file mode 100644 index 00000000..e4a71a57 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests.swift @@ -0,0 +1,41 @@ +// +// AuthenticationHelperTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("AuthenticationHelper") +internal enum AuthenticationHelperTests { + internal static let testPrivateKeyPEM: String = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 + OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r + 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G + -----END PRIVATE KEY----- + """ +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelperTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelperTests.swift deleted file mode 100644 index 665efd73..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelperTests.swift +++ /dev/null @@ -1,392 +0,0 @@ -// -// AuthenticationHelperTests.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing -@testable import MistDemo -@testable import MistKit - -@Suite("AuthenticationHelper Tests") -struct AuthenticationHelperTests { - - // MARK: - Server-to-Server Authentication Tests - - @Test("Server-to-server auth with keyID creates ServerToServerAuthManager") - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func serverToServerAuthWithKeyID() async throws { - // Create a temporary private key file - let tempDir = FileManager.default.temporaryDirectory - let keyFile = tempDir.appendingPathComponent("test_key_\(UUID().uuidString).pem") - - // Use a test private key (this is a dummy key for testing only) - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - try privateKeyPEM.write(to: keyFile, atomically: true, encoding: .utf8) - - defer { - try? FileManager.default.removeItem(at: keyFile) - } - - // Note: This will fail validation because it's a test key, but we can test the setup - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: "test-key-id", - privateKey: nil, - privateKeyFile: keyFile.path, - databaseOverride: nil - ) - - // If we get here, validation succeeded (unlikely with test key) - #expect(result.database == .public) - #expect(result.authMethod.contains("Server-to-server")) - } catch AuthenticationError.invalidServerToServerCredentials { - // Expected - test key won't validate - // But we've confirmed the setup path works - } - } - - @Test("Server-to-server auth with inline private key") - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func serverToServerAuthWithInlineKey() async throws { - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: "test-key-id", - privateKey: privateKeyPEM, - privateKeyFile: nil, - databaseOverride: nil - ) - - #expect(result.database == .public) - #expect(result.authMethod.contains("Server-to-server")) - } catch AuthenticationError.invalidServerToServerCredentials { - // Expected with test key - } - } - - @Test("Server-to-server auth enforces public database") - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func serverToServerEnforcesPublicDatabase() async throws { - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - // Attempt to override with private database should fail - do { - _ = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: "test-key-id", - privateKey: privateKeyPEM, - privateKeyFile: nil, - databaseOverride: "private" - ) - Issue.record("Expected serverToServerRequiresPublicDatabase error") - } catch let error as AuthenticationError { - if case .serverToServerRequiresPublicDatabase = error { - // Expected error - test passes - } else { - Issue.record("Expected serverToServerRequiresPublicDatabase, got \(error)") - } - } - } - - @Test("Server-to-server auth throws on missing private key") - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func serverToServerThrowsOnMissingPrivateKey() async throws { - do { - _ = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: "test-key-id", - privateKey: nil, - privateKeyFile: nil, - databaseOverride: nil - ) - Issue.record("Expected missingPrivateKey error") - } catch let error as AuthenticationError { - if case .missingPrivateKey = error { - // Expected error - test passes - } else { - Issue.record("Expected missingPrivateKey, got \(error)") - } - } - } - - @Test("Server-to-server auth throws on invalid key file path") - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func serverToServerThrowsOnInvalidKeyFile() async throws { - let invalidPath = "/nonexistent/path/to/key.pem" - - do { - _ = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: "test-key-id", - privateKey: nil, - privateKeyFile: invalidPath, - databaseOverride: nil - ) - Issue.record("Should have thrown failedToReadPrivateKeyFile error") - } catch let error as AuthenticationError { - if case .failedToReadPrivateKeyFile(let path, _) = error { - #expect(path == invalidPath) - } else { - Issue.record("Expected failedToReadPrivateKeyFile, got \(error)") - } - } - } - - // MARK: - Web Authentication Tests - - @Test("Web auth defaults to private database") - func webAuthDefaultsToPrivateDatabase() async throws { - // Note: This will fail validation without real credentials - // We're testing the path selection logic - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: "test-web-auth-token", - keyID: nil, - privateKey: nil, - privateKeyFile: nil, - databaseOverride: nil - ) - - #expect(result.database == .private) - #expect(result.authMethod.contains("Web authentication")) - #expect(result.authMethod.contains("private")) - } catch AuthenticationError.invalidWebAuthCredentials { - // Expected with test credentials - but we know it chose the right path - } catch is TokenManagerError { - // Expected - MistKit validates token format before AuthenticationHelper wraps it - } - } - - @Test("Web auth allows public database override") - func webAuthAllowsPublicDatabaseOverride() async throws { - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: "test-web-auth-token", - keyID: nil, - privateKey: nil, - privateKeyFile: nil, - databaseOverride: "public" - ) - - #expect(result.database == .public) - #expect(result.authMethod.contains("Web authentication")) - #expect(result.authMethod.contains("public")) - } catch AuthenticationError.invalidWebAuthCredentials { - // Expected with test credentials - } catch is TokenManagerError { - // Expected - MistKit validates token format before AuthenticationHelper wraps it - } - } - - @Test("Web auth respects private database override") - func webAuthRespectsPrivateDatabaseOverride() async throws { - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: "test-web-auth-token", - keyID: nil, - privateKey: nil, - privateKeyFile: nil, - databaseOverride: "private" - ) - - #expect(result.database == .private) - } catch AuthenticationError.invalidWebAuthCredentials { - // Expected with test credentials - } catch is TokenManagerError { - // Expected - MistKit validates token format before AuthenticationHelper wraps it - } - } - - // MARK: - API-Only Authentication Tests - - @Test("API-only auth enforces public database") - func apiOnlyEnforcesPublicDatabase() async throws { - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: nil, - privateKey: nil, - privateKeyFile: nil, - databaseOverride: nil - ) - - #expect(result.database == .public) - #expect(result.authMethod.contains("API-only")) - } catch AuthenticationError.invalidAPIToken { - // Expected with test token - } catch is TokenManagerError { - // Expected - MistKit validates token format before AuthenticationHelper wraps it - } - } - - @Test("API-only auth throws on private database request") - func apiOnlyThrowsOnPrivateDatabaseRequest() async throws { - do { - _ = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: nil, - keyID: nil, - privateKey: nil, - privateKeyFile: nil, - databaseOverride: "private" - ) - Issue.record("Expected privateRequiresWebAuth error") - } catch let error as AuthenticationError { - if case .privateRequiresWebAuth = error { - // Expected error - test passes - } else { - Issue.record("Expected privateRequiresWebAuth, got \(error)") - } - } - } - - // MARK: - Token Resolution Tests - - @Test("resolveAPIToken returns provided token when not empty") - func resolveAPITokenReturnsProvidedToken() { - let token = "my-api-token" - let resolved = AuthenticationHelper.resolveAPIToken(token) - #expect(resolved == token) - } - - @Test("resolveAPIToken checks environment when empty") - func resolveAPITokenChecksEnvironment() { - let resolved = AuthenticationHelper.resolveAPIToken("") - // Should return environment value or empty string (it's a String, not optional) - // This test just verifies the function executes without error - _ = resolved - } - - @Test("resolveWebAuthToken returns provided token when not empty") - func resolveWebAuthTokenReturnsProvidedToken() { - let token = "my-web-auth-token" - let resolved = AuthenticationHelper.resolveWebAuthToken(token) - #expect(resolved == token) - } - - @Test("resolveWebAuthToken returns nil for empty string") - func resolveWebAuthTokenReturnsNilForEmpty() { - let resolved = AuthenticationHelper.resolveWebAuthToken("") - // Should return nil if environment variable not set - if ProcessInfo.processInfo.environment["CLOUDKIT_WEB_AUTH_TOKEN"] == nil { - #expect(resolved == nil) - } - } - - @Test("resolveWebAuthToken checks environment variable") - func resolveWebAuthTokenChecksEnvironment() { - // Set environment variable temporarily - setenv("CLOUDKIT_WEB_AUTH_TOKEN", "env-token", 1) - defer { unsetenv("CLOUDKIT_WEB_AUTH_TOKEN") } - - let resolved = AuthenticationHelper.resolveWebAuthToken("") - #expect(resolved == "env-token") - } - - // MARK: - Authentication Method Priority Tests - - @Test("Server-to-server takes precedence over web auth") - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func serverToServerTakesPrecedence() async throws { - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: "test-web-auth-token", // Should be ignored - keyID: "test-key-id", - privateKey: privateKeyPEM, - privateKeyFile: nil, - databaseOverride: nil - ) - - #expect(result.database == .public) - #expect(result.authMethod.contains("Server-to-server")) - } catch AuthenticationError.invalidServerToServerCredentials { - // Expected with test credentials - } - } - - @Test("Web auth takes precedence over API-only") - func webAuthTakesPrecedence() async throws { - do { - let result = try await AuthenticationHelper.setupAuthentication( - apiToken: "test-api-token", - webAuthToken: "test-web-auth-token", - keyID: nil, - privateKey: nil, - privateKeyFile: nil, - databaseOverride: nil - ) - - #expect(result.authMethod.contains("Web authentication")) - #expect(!result.authMethod.contains("API-only")) - } catch AuthenticationError.invalidWebAuthCredentials { - // Expected with test credentials - } catch is TokenManagerError { - // Expected - MistKit validates token format before AuthenticationHelper wraps it - } - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/EnvironmentTraits.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/EnvironmentTraits.swift new file mode 100644 index 00000000..e1f72e64 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/EnvironmentTraits.swift @@ -0,0 +1,56 @@ +// +// EnvironmentTraits.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +/// A `TestTrait` / `SuiteTrait` that scopes a fake environment for the test. +/// Apply with `.mockEnvironment(["KEY": "value"])` to declare the environment +/// the test expects, then read it via `MockEnvironment.reader`. +internal struct MockEnvironmentTrait: TestTrait, SuiteTrait, TestScoping { + internal let values: [String: String] + + internal func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await MockEnvironment.$values.withValue(values) { + try await function() + } + } +} + +extension Trait where Self == MockEnvironmentTrait { + /// Scope a fake environment dictionary for the test. The test reads it via + /// `MockEnvironment.reader`, which is safe to share across parallel tests + /// because each test gets its own task-local copy. + internal static func mockEnvironment(_ values: [String: String]) -> Self { + .init(values: values) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift new file mode 100644 index 00000000..ecabbaab --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift @@ -0,0 +1,89 @@ +// +// LoopbackAuthorityTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("LoopbackAuthority Tests") +internal struct LoopbackAuthorityTests { + @Test( + "Accepts recognized loopback authorities", + arguments: [ + "localhost", + "127.0.0.1", + "[::1]", + "localhost:8080", + "127.0.0.1:8080", + "[::1]:8080", + ] + ) + internal func accepts(authority: String) { + #expect(LoopbackAuthority.isLoopback(authority)) + } + + @Test( + "Rejects non-loopback authorities", + arguments: [ + "", + "evil.com", + "evil.com:8080", + "example.com", + "example.com:443", + "api.apple-cloudkit.com", + "localhost.evil.com", + "localhost.evil.com:8080", + "localhostx", + "127.0.0.1.evil.com", + "127.0.0.1.evil.com:8080", + "127.0.0.2", + "10.0.0.1", + "10.0.0.1:8080", + "192.168.1.1:8080", + "0.0.0.0", + "[::2]", + "[2001:db8::1]", + "[2001:db8::1]:8080", + "[::1].evil.com", + ] + ) + internal func rejects(authority: String) { + #expect(!LoopbackAuthority.isLoopback(authority)) + } + + @Test("Rejects malformed bracketed IPv6 (missing closing bracket)") + internal func malformedMissingCloseBracket() { + #expect(!LoopbackAuthority.isLoopback("[::1")) + } + + @Test("Rejects bracketed IPv6 with trailing junk instead of port") + internal func malformedTrailingJunk() { + #expect(!LoopbackAuthority.isLoopback("[::1]junk")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/MockEnvironment.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/MockEnvironment.swift new file mode 100644 index 00000000..32c77e7d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/MockEnvironment.swift @@ -0,0 +1,44 @@ +// +// MockEnvironment.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Task-local environment dictionary that participating tests read instead of +/// `ProcessInfo`. Carrying the env in task-local storage keeps tests parallel-safe +/// (no mutation of process-global state) and works on every platform — including +/// Windows, where POSIX `setenv`/`unsetenv` aren't in scope. +internal enum MockEnvironment { + @TaskLocal internal static var values: [String: String] = [:] + + /// An environment reader closure bound to whatever `MockEnvironment.values` + /// is set to in the current task. Pass this into APIs that accept an injected + /// environment reader. + internal static var reader: @Sendable (String) -> String? { + let snapshot = values + return { snapshot[$0] } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/TestPlatform.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/TestPlatform.swift new file mode 100644 index 00000000..62598d61 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/TestPlatform.swift @@ -0,0 +1,44 @@ +// +// TestPlatform.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Compile-time platform constants exposed as runtime values so tests can read +/// them via Swift Testing traits like `.enabled(if:)` / `.disabled(if:)` — +/// keeping the gating in a trait on the test rather than `#if` around it. +internal enum TestPlatform { + /// True when the test binary is targeting wasm32. The wasm32 CooperativeExecutor + /// doesn't time-slice short-timeout races the same way Darwin/Linux dispatch + /// do, and Int is 32-bit so values exceeding Int32.max trap on conversion. + internal static let isWasm32: Bool = { + #if arch(wasm32) + return true + #else + return false + #endif + }() +} diff --git a/Examples/MistDemo/examples/README.md b/Examples/MistDemo/examples/README.md index bafaaba8..4bef5063 100644 --- a/Examples/MistDemo/examples/README.md +++ b/Examples/MistDemo/examples/README.md @@ -94,10 +94,10 @@ swift run mistdemo query --record-type Note --limit 10 swift run mistdemo query --filter "title:contains:important" --filter "priority:gt:5" # With sorting -swift run mistdemo query --sort "createdAt:desc" --limit 5 +swift run mistdemo query --sort "index:desc" --limit 5 # Field selection -swift run mistdemo query --fields "title,createdAt,priority" +swift run mistdemo query --fields "title,index" ``` ### 📤 upload-asset.sh diff --git a/Examples/MistDemo/examples/query-records.sh b/Examples/MistDemo/examples/query-records.sh index b38a7356..7774540e 100755 --- a/Examples/MistDemo/examples/query-records.sh +++ b/Examples/MistDemo/examples/query-records.sh @@ -63,18 +63,18 @@ swift run mistdemo query $COMMON_ARGS --record-type Note \ --output-format table echo "" -echo -e "${GREEN}Example 4: Query with sorting (newest first)${NC}" -echo "Command: swift run mistdemo query $COMMON_ARGS --sort \"createdAt:desc\"" +echo -e "${GREEN}Example 4: Query with sorting (by index, descending)${NC}" +echo "Command: swift run mistdemo query $COMMON_ARGS --sort \"index:desc\"" swift run mistdemo query $COMMON_ARGS --record-type Note \ - --sort "createdAt:desc" \ + --sort "index:desc" \ --limit 5 \ --output-format table echo "" echo -e "${GREEN}Example 5: Query with field selection${NC}" -echo "Command: swift run mistdemo query $COMMON_ARGS --fields \"title,createdAt,priority\"" +echo "Command: swift run mistdemo query $COMMON_ARGS --fields \"title,index\"" swift run mistdemo query $COMMON_ARGS --record-type Note \ - --fields "title,createdAt,priority" \ + --fields "title,index" \ --limit 5 \ --output-format table diff --git a/Examples/MistDemo/mise.toml b/Examples/MistDemo/mise.toml new file mode 100644 index 00000000..99048973 --- /dev/null +++ b/Examples/MistDemo/mise.toml @@ -0,0 +1,7 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"aqua:peripheryapp/periphery" = "3.7.4" diff --git a/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml new file mode 100644 index 00000000..3e9f6b46 --- /dev/null +++ b/Examples/MistDemo/project.yml @@ -0,0 +1,94 @@ +name: MistDemoApp + +options: + bundleIdPrefix: ${BUNDLE_ID_PREFIX} + deploymentTarget: + macOS: "15.0" + iOS: "17.0" + createIntermediateGroups: true + developmentLanguage: en + +packages: + MistDemo: + path: . + +settings: + base: + SWIFT_VERSION: "6.0" + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + PRODUCT_NAME: MistDemoApp + # Distinct from the SPM library product `MistDemoApp` so their + # swiftmodules don't collide in the build directory. + PRODUCT_MODULE_NAME: MistDemoAppShell + PRODUCT_BUNDLE_IDENTIFIER: ${BUNDLE_ID_PREFIX}.MistDemoApp + DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM} + GENERATE_INFOPLIST_FILE: "YES" + INFOPLIST_KEY_CFBundleDisplayName: "MistDemo (Native)" + CODE_SIGN_STYLE: Automatic + CODE_SIGN_ENTITLEMENTS: MistDemoApp.entitlements + SWIFT_EMIT_LOC_STRINGS: "YES" + ENABLE_USER_SCRIPT_SANDBOXING: "YES" + GCC_C_LANGUAGE_STANDARD: gnu17 + CLANG_ENABLE_MODULES: "YES" + SWIFT_STRICT_CONCURRENCY: complete + +targets: + MistDemoApp-macOS: + type: application + platform: macOS + sources: + - path: App + type: group + dependencies: + - package: MistDemo + product: MistDemoApp + settings: + base: + INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools" + INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 BrightDigit. All rights reserved." + ENABLE_HARDENED_RUNTIME: "YES" + + MistDemoApp-iOS: + type: application + platform: iOS + sources: + - path: App + type: group + dependencies: + - package: MistDemo + product: MistDemoApp + settings: + base: + TARGETED_DEVICE_FAMILY: "1,2" + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: "YES" + INFOPLIST_KEY_UILaunchScreen_Generation: "YES" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + +schemes: + MistDemoApp-macOS: + build: + targets: + MistDemoApp-macOS: all + run: + config: Debug + environmentVariables: + CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} + test: + config: Debug + archive: + config: Release + + MistDemoApp-iOS: + build: + targets: + MistDemoApp-iOS: all + run: + config: Debug + environmentVariables: + CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} + test: + config: Debug + archive: + config: Release diff --git a/Examples/MistDemo/schema.ckdb b/Examples/MistDemo/schema.ckdb index 84b4b3f5..b8243bff 100644 --- a/Examples/MistDemo/schema.ckdb +++ b/Examples/MistDemo/schema.ckdb @@ -2,11 +2,11 @@ DEFINE SCHEMA RECORD TYPE Note ( "___recordID" REFERENCE QUERYABLE, + "___createTime" TIMESTAMP QUERYABLE SORTABLE, + "___modTime" TIMESTAMP QUERYABLE SORTABLE, "title" STRING QUERYABLE SORTABLE SEARCHABLE, "index" INT64 QUERYABLE SORTABLE, "image" ASSET, - "createdAt" TIMESTAMP QUERYABLE SORTABLE, - "modified" INT64 QUERYABLE, GRANT READ, CREATE, WRITE TO "_creator", GRANT READ, CREATE, WRITE TO "_icloud", diff --git a/Mintfile b/Mintfile deleted file mode 100644 index 3586a2be..00000000 --- a/Mintfile +++ /dev/null @@ -1,4 +0,0 @@ -swiftlang/swift-format@602.0.0 -realm/SwiftLint@0.62.2 -peripheryapp/periphery@3.2.0 -apple/swift-openapi-generator@1.10.3 diff --git a/Package.resolved b/Package.resolved index 91a02e89..8664d57e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "f522a83bf637ef80939be380e3541af820ec621a68e0d1d84ced8f8f198c36c5", + "originHash" : "7ac2865334281344d99fa69022b66507aad8e159bfb58a3fab0660c679da4515", "pins" : [ { "identity" : "swift-asn1", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", - "version" : "1.4.0" + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", - "version" : "1.2.1" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "7722cf8eac05c1f1b5b05895b04cfcc29576d9be", - "version" : "1.8.3" + "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", + "version" : "1.11.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" + "revision" : "576a65b4ffb8c12ddad4950dc21eea2ef071bec2", + "version" : "1.3.0" } } ], diff --git a/Package.swift b/Package.swift index d59da392..69055848 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,17 @@ import PackageDescription // MARK: - Swift Settings Configuration +// Swift settings for the generated OpenAPI target. swift-openapi-generator +// emits bare `import Foundation` / `import OpenAPIRuntime`; under SE-0409 +// (InternalImportsByDefault) those flip to `internal`, which breaks the +// public initializers on the generated `Client`. Leave InternalImportsByDefault +// off for the generated target. +let generatedSwiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("FullTypedThrows"), +] + // Base Swift settings for all platforms let swiftSettings: [SwiftSetting] = [ // Swift 6.2 Upcoming Features (not yet enabled by default) @@ -88,22 +99,34 @@ let package = Package( name: "MistKit", targets: ["MistKit"] ), + .library( + name: "MistKitOpenAPI", + targets: ["MistKitOpenAPI"] + ), ], dependencies: [ // Swift OpenAPI Runtime dependencies .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.2.0"), // Crypto library for cross-platform cryptographic operations - .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "4.0.0"), // Logging library for cross-platform logging .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MistKitOpenAPI", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ], + swiftSettings: generatedSwiftSettings + ), .target( name: "MistKit", dependencies: [ + "MistKitOpenAPI", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), // URLSession transport only available on non-WASM platforms .product( @@ -118,7 +141,7 @@ let package = Package( ), .testTarget( name: "MistKitTests", - dependencies: ["MistKit"], + dependencies: ["MistKit", "MistKitOpenAPI"], swiftSettings: swiftSettings ), ] diff --git a/README.md b/README.md index a96bad0c..afdb88d9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![Maintainability](https://qlty.sh/badges/55637213-d307-477e-a710-f9dba332d955/maintainability.svg)](https://qlty.sh/gh/brightdigit/projects/MistKit) [![Documentation](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/brightdigit/MistKit/documentation) -A Swift Package for Server-Side and Command-Line Access to CloudKit Web Services +A Swift Package for Server-Side and Command-Line Access to [CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices) ## Table of Contents - [Overview](#overview) @@ -36,7 +36,7 @@ A Swift Package for Server-Side and Command-Line Access to CloudKit Web Services ## Overview -MistKit provides a modern Swift interface to CloudKit Web Services REST API, enabling cross-platform CloudKit access for server-side Swift applications, command-line tools, and platforms where the CloudKit framework isn't available. +MistKit provides a modern Swift interface to [CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices) REST API, enabling cross-platform CloudKit access for server-side Swift applications, command-line tools, and platforms where the [CloudKit framework](https://developer.apple.com/documentation/cloudkit) isn't available. Built with Swift concurrency (async/await) and designed for modern Swift applications, MistKit supports all three CloudKit authentication methods and provides type-safe access to CloudKit operations. @@ -46,7 +46,7 @@ Built with Swift concurrency (async/await) and designed for modern Swift applica - **⚡ Modern Swift**: Built with Swift 6 concurrency features and structured error handling - **🔐 Multiple Authentication Methods**: API token, web authentication, and server-to-server authentication - **🛡️ Type-Safe**: Comprehensive type safety with Swift's type system -- **📋 OpenAPI-Based**: Generated from CloudKit Web Services OpenAPI specification +- **📋 OpenAPI-Based**: Generated from CloudKit Web Services [OpenAPI specification](https://www.openapis.org/) using [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) - **🔒 Secure**: Built-in security best practices and credential management ## Getting Started @@ -57,7 +57,7 @@ Add MistKit to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-alpha.5") + .package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-beta.1") ] ``` @@ -91,56 +91,72 @@ Or add it through Xcode: #### 1. Choose Your Authentication Method -MistKit supports three authentication methods depending on your use case: +MistKit supports three credential types via the `Credentials` value. The service +does **not** carry a database — each operation picks its database (and signing +method, for the public database) at the call site. -##### API Token (Container-level access) +##### API Token (read-only against the public database) ```swift import MistKit -let service = try CloudKitService( +let credentials = try Credentials( + apiAuth: APICredentials( + apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]! + ) +) +let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", - apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]! + credentials: credentials ) ``` -##### Web Authentication (User-specific access) +##### Web Authentication (user-context routes, private/shared database) ```swift -let service = try CloudKitService( +let credentials = try Credentials( + apiAuth: APICredentials( + apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]!, + webAuthToken: userWebAuthToken + ) +) +let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", - apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]!, - webAuthToken: userWebAuthToken + credentials: credentials ) ``` -##### Server-to-Server (Enterprise access, public database only) +##### Server-to-Server (public database only) ```swift -let serverManager = try ServerToServerAuthManager( - keyIdentifier: ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"]!, - privateKeyData: privateKeyData +let credentials = try Credentials( + serverToServer: ServerToServerCredentials( + keyID: ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"]!, + privateKey: .file(path: "private_key.pem") + ) ) - -let service = try CloudKitService( +let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", - tokenManager: serverManager, - environment: .production, - database: .public + credentials: credentials, + environment: .production ) ``` -#### 2. Create CloudKit Service +Provide both `apiAuth` and `serverToServer` to a single `Credentials` when one +service must hit public-database routes via S2S signing **and** user-context +routes via web-auth — MistKit picks the appropriate token manager per call. + +#### 2. Call an Operation (database chosen per call) ```swift -do { - let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.MyApp", - apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]! - ) - // Use service for CloudKit operations -} catch { - print("Failed to create service: \\(error)") -} +let records = try await service.queryRecords( + recordType: "Post", + database: .public(.prefers(.serverToServer)) +) ``` +`Database.public` carries a `PublicAuthPreference`: +`.prefers(.serverToServer)` / `.prefers(.webAuth)` (fall back if not configured) +or `.requires(.serverToServer)` / `.requires(.webAuth)` (throw if not configured). +Private/shared always use web-auth. + ## Usage ### Authentication @@ -159,21 +175,28 @@ do { 3. **Use in Code**: ```swift - let service = try CloudKitService( + let credentials = try Credentials( + apiAuth: APICredentials( + apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]! + ) + ) + let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", - apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]! + credentials: credentials ) ``` #### Web Authentication -Web authentication enables user-specific operations and requires both an API token and a web authentication token obtained through CloudKit JS authentication. +Web authentication enables user-specific operations and requires both an API token and a web authentication token. The token can be obtained either through [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs) authentication (browser flow) or from an iOS/macOS app via [`CKFetchWebAuthTokenOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation), which exchanges the user's existing iCloud session for a token your backend can use. ```swift -let service = try CloudKitService( +let credentials = try Credentials( + apiAuth: APICredentials(apiToken: apiToken, webAuthToken: webAuthToken) +) +let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", - apiToken: apiToken, - webAuthToken: webAuthToken + credentials: credentials ) ``` @@ -192,19 +215,40 @@ Server-to-server authentication provides enterprise-level access using ECDSA P-2 2. **Upload Public Key**: Upload the public key to Apple Developer Console -3. **Use in Code**: +3. **Use in Code** (the simplest path — `Credentials` resolves the PEM at first use): ```swift - let privateKeyData = try Data(contentsOf: URL(fileURLWithPath: "private_key.pem")) + let credentials = try Credentials( + serverToServer: ServerToServerCredentials( + keyID: "your_key_id", + privateKey: .file(path: "private_key.pem") + ) + ) + let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + credentials: credentials, + environment: .production + ) + + // Each call selects its database scope explicitly: + let records = try await service.queryRecords( + recordType: "Post", + database: .public(.requires(.serverToServer)) + ) + ``` + + To plug in a custom `TokenManager` (e.g. with shared connection pooling), + use the `tokenManager:` initializer instead: + ```swift + let pemString = try String(contentsOfFile: "private_key.pem", encoding: .utf8) let serverManager = try ServerToServerAuthManager( - keyIdentifier: "your_key_id", - privateKeyData: privateKeyData + keyID: "your_key_id", + pemString: pemString ) - let service = try CloudKitService( + let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", tokenManager: serverManager, - environment: .production, - database: .public + environment: .production ) ``` @@ -214,15 +258,24 @@ MistKit provides comprehensive error handling with typed errors: ```swift do { - let service = try CloudKitService( + let credentials = try Credentials( + apiAuth: APICredentials(apiToken: apiToken) + ) + let service = CloudKitService( containerIdentifier: "iCloud.com.example.MyApp", - apiToken: apiToken + credentials: credentials + ) + // Perform operations — each call picks its database, e.g.: + let posts = try await service.queryRecords( + recordType: "Post", + database: .public(.prefers(.serverToServer)) ) - // Perform operations } catch let error as CloudKitError { print("CloudKit error: \\(error.localizedDescription)") } catch let error as TokenManagerError { print("Authentication error: \\(error.localizedDescription)") +} catch let error as CredentialsValidationError { + print("Credentials error: \\(error.localizedDescription)") } catch { print("Unexpected error: \\(error)") } @@ -230,33 +283,24 @@ do { #### Error Types -- **`CloudKitError`**: CloudKit Web Services API errors +- **`CloudKitError`**: CloudKit Web Services API errors (typed throws on every operation) +- **`CredentialsValidationError`**: Surfaces when `Credentials.init` is called with neither `apiAuth` nor `serverToServer` - **`TokenManagerError`**: Authentication and credential errors - **`TokenStorageError`**: Token storage and persistence errors ### Advanced Usage -#### Using AsyncHTTPClient Transport +#### HTTP Transport -For server-side applications, MistKit can use [swift-openapi-async-http-client](https://github.com/swift-server/swift-openapi-async-http-client) as the underlying HTTP transport. This is particularly useful for server-side Swift applications that need robust HTTP client capabilities. +Non-WASI platforms default to `URLSessionTransport` — no transport plumbing is +required. On Apple platforms, the default convenience initializer used in the +examples above wires up `URLSessionTransport` automatically. -```swift -import MistKit -import OpenAPIAsyncHTTPClient - -// AsyncHTTPClient instance usually supplied by the Server API -let httpClient : HTTPClient - -// Create the transport -let transport = AsyncHTTPClientTransport(client: httpClient) - -// Use with CloudKit service -let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.MyApp", - apiToken: apiToken, - transport: transport -) -``` +WASI builds use the generic, transport-accepting initializer; see +`Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift` for the +internal entry point. A custom transport on Apple platforms (e.g. for +server-side Swift with AsyncHTTPClient) is not yet exposed in the public +v1.0.0-beta surface — track via the project roadmap. #### Adaptive Token Manager @@ -269,7 +313,7 @@ let adaptiveManager = AdaptiveTokenManager( ) // Later, upgrade to web authentication -try await adaptiveManager.upgradeToWebAuth(webAuthToken: webToken) +try await adaptiveManager.upgradeToWebAuthentication(webAuthToken: webToken) ``` ### Examples @@ -277,13 +321,26 @@ try await adaptiveManager.upgradeToWebAuth(webAuthToken: webToken) Check out the `Examples/` directory for complete working examples: - **[MistDemo](Examples/MistDemo/)**: Web-based CloudKit authentication demo with automatic token capture -- **[BushelCloud](Examples/BushelCloud/)**: Server-to-Server auth demo syncing macOS restore images, Xcode, and Swift versions -- **[CelestraCloud](Examples/CelestraCloud/)**: RSS reader demonstrating CloudKit query filtering, sorting, and web etiquette patterns +- **[BushelCloud](Examples/BushelCloud/)**: Server-to-Server auth demo syncing macOS restore images, Xcode, and Swift versions — backend for the [Bushel app](https://getbushel.app) +- **[CelestraCloud](Examples/CelestraCloud/)**: RSS reader demonstrating CloudKit query filtering, sorting, and web etiquette patterns — backend for the [Celestra app](https://celestr.app), built with [SyndiKit](https://github.com/brightdigit/SyndiKit) ## Documentation - **[API Documentation](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation)**: Complete API reference -- **[CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices)**: Apple's official CloudKit Web Services documentation + +### Apple References + +- **[CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices)**: Official CloudKit Web Services REST API documentation +- **[CloudKit framework](https://developer.apple.com/documentation/cloudkit)**: On-device CloudKit framework (iOS/macOS) +- **[CloudKit JS](https://developer.apple.com/documentation/cloudkitjs)**: Browser-based CloudKit access used for web auth token capture +- **[CKFetchWebAuthTokenOperation](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation)**: iOS/macOS API for exchanging an iCloud session for a web auth token + +### Related Swift Packages + +- **[swift-openapi-generator](https://github.com/apple/swift-openapi-generator)**: Generates type-safe Swift clients from OpenAPI specs +- **[swift-openapi-async-http-client](https://github.com/swift-server/swift-openapi-async-http-client)**: AsyncHTTPClient transport for OpenAPI clients +- **[AsyncHTTPClient](https://github.com/swift-server/async-http-client)**: HTTP client for server-side Swift +- **[swift-crypto](https://github.com/apple/swift-crypto)**: Cross-platform crypto used for ECDSA P-256 server-to-server signing ## License @@ -338,8 +395,34 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Fetching Record Changes (records/changes)](https://github.com/brightdigit/MistKit/issues/40) ✅ - [x] [Fetching Zones by Identifier (zones/lookup)](https://github.com/brightdigit/MistKit/issues/44) ✅ - [x] [Fetching Zone Changes (zones/changes)](https://github.com/brightdigit/MistKit/issues/48) ✅ -- [x] Fix QueryFilter IN/NOT_IN serialization ✅ -- [x] MistDemo integration test runner and new commands (`fetch-changes`, `lookup-zones`, `test-integration`, `test-private`, `demo-in-filter`) ✅ +- [x] [Fix QueryFilter IN/NOT_IN serialization](https://github.com/brightdigit/MistKit/issues/192) ✅ + +### v1.0.0-beta.1 + +**Querying & Sync** +- [x] Query pagination with continuation markers ([#306](https://github.com/brightdigit/MistKit/pull/306)) ✅ +- [x] Operation classification & batch sync result tracking ([#296](https://github.com/brightdigit/MistKit/pull/296)) ✅ + +**Authentication** +- [x] `AuthenticationMiddleware` refactor — each `Authenticator` applies itself ([#294](https://github.com/brightdigit/MistKit/pull/294)) ✅ +- [x] Strengthened environment & database configuration validation ([#293](https://github.com/brightdigit/MistKit/pull/293)) ✅ + +**Error Handling** +- [x] Typed `TokenManagerError` and safe `RecordOperation` conversion ([#305](https://github.com/brightdigit/MistKit/pull/305)) ✅ +- [x] Move `CloudKitResponseType` defaults to protocol extension ([#292](https://github.com/brightdigit/MistKit/pull/292)) ✅ + +**Concurrency** +- [x] Replace custom `AsyncChannel` with `swift-async-algorithms` ([#280](https://github.com/brightdigit/MistKit/pull/280)) ✅ + +**MistDemo** +- [x] `--database` flag and `demo-errors` command ([#282](https://github.com/brightdigit/MistKit/pull/282)) ✅ +- [x] Test split, CRUD commands, auth fix, native app ([#271](https://github.com/brightdigit/MistKit/pull/271) / [#273](https://github.com/brightdigit/MistKit/pull/273)) ✅ +- [x] `IntegrationTestRunner` refactored into protocol-based phase pipeline ([#283](https://github.com/brightdigit/MistKit/pull/283)) ✅ + +**Tooling & CI** +- [x] Test suite improvements ([#286](https://github.com/brightdigit/MistKit/pull/286) / [#287](https://github.com/brightdigit/MistKit/pull/287)) ✅ +- [x] CI updates for May 2026 ([#277](https://github.com/brightdigit/MistKit/pull/277)) ✅ +- [x] Fail lint job when any command fails ([#303](https://github.com/brightdigit/MistKit/pull/303)) ✅ ### v1.0.0-alpha.X @@ -383,4 +466,4 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. --- -*MistKit: Bringing CloudKit to every Swift platform* 🌟 \ No newline at end of file +*MistKit: Bringing CloudKit to every Swift platform* 🌟 diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 7a9726a2..2f5045ee 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,4 +1,34 @@ -## What's Changed +## 1.0.0-beta.1 + +### Querying & Sync +* Add query pagination support with continuation markers by @leogdion in https://github.com/brightdigit/MistKit/pull/306 +* Add operation classification and batch sync result tracking by @leogdion in https://github.com/brightdigit/MistKit/pull/296 + +### Authentication +* Refactor AuthenticationMiddleware so each Authenticator applies itself by @leogdion in https://github.com/brightdigit/MistKit/pull/294 +* Strengthen environment and database configuration validation by @leogdion in https://github.com/brightdigit/MistKit/pull/293 + +### Error Handling +* Improve error handling: typed TokenManagerError and safe RecordOperation conversion by @leogdion in https://github.com/brightdigit/MistKit/pull/305 +* Move CloudKitResponseType default implementations to protocol extension by @leogdion in https://github.com/brightdigit/MistKit/pull/292 + +### Concurrency +* Replace custom AsyncChannel with swift-async-algorithms by @leogdion in https://github.com/brightdigit/MistKit/pull/280 + +### MistDemo +* MistDemo: --database flag + demo-errors command by @leogdion in https://github.com/brightdigit/MistKit/pull/282 +* Refactor IntegrationTestRunner into protocol-based phase pipeline by @leogdion in https://github.com/brightdigit/MistKit/pull/283 +* MistDemo improvements: test split, CRUD, auth fix, native app by @leogdion in https://github.com/brightdigit/MistKit/pull/271 / https://github.com/brightdigit/MistKit/pull/273 + +### Tooling & CI +* Test suite improvements for v1.0.0-beta.1 by @leogdion in https://github.com/brightdigit/MistKit/pull/286 / https://github.com/brightdigit/MistKit/pull/287 +* CI Updates for May 2026 by @leogdion in https://github.com/brightdigit/MistKit/pull/277 +* Fail lint job when any command fails, not only in STRICT mode by @leogdion in https://github.com/brightdigit/MistKit/pull/303 + +**Full Changelog**: https://github.com/brightdigit/MistKit/compare/1.0.0-alpha.5...1.0.0-beta.1 + +## 1.0.0-alpha.5 + * Add lookupZones, fetchRecordChanges, and uploadAssets operations by @leogdion in https://github.com/brightdigit/MistKit/pull/204 * Fix QueryFilter IN/NOT_IN serialization by @leogdion in https://github.com/brightdigit/MistKit/pull/205 * Migrate server-side CloudKit tutorial content by @leogdion in https://github.com/brightdigit/MistKit/pull/248 diff --git a/SWIFT_COMPILER_BUG.md b/SWIFT_COMPILER_BUG.md new file mode 100644 index 00000000..832341fe --- /dev/null +++ b/SWIFT_COMPILER_BUG.md @@ -0,0 +1,212 @@ +# Swift 6.3 SIL miscompile: `MandatoryAllocBoxToStack` crashes on destructured-enum `catch` pattern with a literal + +## Summary + +Swift 6.3 (`swiftlang-6.3.0.123.5`) crashes in the `MandatoryAllocBoxToStack` +SIL pass (`StackNesting::fixNesting`, signal 11) while compiling a module that +contains a `catch` clause whose destructured-enum pattern matches **any +literal value** in **any associated-value position**, in combination with a +specific `RecordManaging` protocol shape that includes a default-argumented +`async throws` extension method built on top of a primitive protocol +requirement. + +The crash is deterministic. Plain `catch`, all-wildcard destructured catch, +and the equivalent pattern moved into a `guard case` all build without issue — +only a `catch` with at least one literal in the pattern triggers the +miscompile. + +## Environment + +- **Compiler**: Apple Swift version 6.3 (`swiftlang-6.3.0.123.5 clang-2100.0.123.102`) +- **swift-driver**: 1.148.6 +- **Target**: `arm64-apple-macosx26.0` +- **Host**: macOS 26.0 (Darwin 25.4.0), Apple Silicon +- **Tools-version (failing)**: any of 6.1 / 6.2 / 6.3 — independent of the package's `swift-tools-version` + +## Reproducer + +```bash +git clone git@github.com:brightdigit/MistKit.git +cd MistKit +git checkout 302-redesign-recordmanaging-experiment +# Commit: d2178f3ba69217ee59d887be011e71e6e3d9d79e +cd Examples/MistDemo +swift build +``` + +The crash occurs during compilation of the `MistDemoKit` module while the +mandatory diagnostic SIL pipeline runs `MandatoryAllocBoxToStack`. + +## Crash output (abridged) + +``` +4. While evaluating request ExecuteSILPipelineRequest(Run pipelines + { Mandatory Diagnostic Passes + Enabling Optimization Passes } on SIL + for MistDemoKit) +5. While running pass #54 SILModuleTransform "MandatoryAllocBoxToStack". + +Stack: +4 swift-frontend swift::StackNesting::fixNesting(swift::SILFunction*) + 4508 +5 swift-frontend BridgedPassContext::fixStackNesting(BridgedFunction) const + 32 +6 swift-frontend Optimizer.tryConvertBoxesToStack + 10940 +7 swift-frontend Optimizer.mandatoryAllocBoxToStack closure + 388 +8 swift-frontend swift::SILPassManager::executePassPipelinePlan + 14624 +9 swift-frontend swift::SimpleRequest<…ExecuteSILPipelineRequest…> + 48 +… +11 swift-frontend swift::runSILDiagnosticPasses(swift::SILModule&) + 432 +12 swift-frontend swift::CompilerInstance::performSILProcessing + 656 +``` + +The crashing pass is the new Swift-implemented optimizer module's +`mandatoryAllocBoxToStack` calling its `tryConvertBoxesToStack` helper, which +calls back into C++ via `BridgedPassContext::fixStackNesting`, where +`StackNesting::fixNesting` segfaults. + +## Trigger + +The crashing site is the `catch` clause at +`Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift:55`: + +```swift +internal func run( + input: CreatedRecordNames, context: PhaseContext +) async throws -> NoState { + do { + let records = try await context.service.queryRecords( + recordType: IntegrationTestData.recordType + ) + // ... uses `records` ... + } catch CloudKitError.httpErrorWithDetails(statusCode: 404, serverErrorCode: _, reason: _) { + // <-- this catch crashes the SIL pass + print("…") + } + return NoState() +} +``` + +`context.service` is typed as a `RecordManaging` existential / generic. The +relevant protocol shape (introduced in commit `d2178f3` on this branch) is: + +```swift +public protocol RecordManaging { + func executeBatchOperations(_ operations: [RecordOperation], recordType: String) async throws + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + func queryRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + limit: Int?, + desiredKeys: [String]?, + continuationMarker: String? + ) async throws -> QueryResult +} + +extension RecordManaging { + @available(*, deprecated, message: "…") + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + public func queryRecords(recordType: String) async throws -> [RecordInfo] { + let result = try await queryRecords( + recordType: recordType, + filters: nil, sortBy: nil, limit: nil, + desiredKeys: nil, continuationMarker: nil + ) + return result.records + } + // also: queryAllRecords with default-argumented overloads, see + // Sources/MistKit/Protocols/RecordManaging.swift +} +``` + +`CloudKitError` is defined in MistKit and includes the +`httpErrorWithDetails(statusCode: Int, serverErrorCode: String, reason: String?)` +case. + +## Bisection ladder + +I narrowed the crash with successive edits to the body of +`QueryRecordsPhase.run`, clean-rebuilding between each change: + +| `run` body content | Result | +| -------------------------------------------------------------------------------------------------------- | --------- | +| empty (`return NoState()`) | builds | +| + `queryRecords` call + plain `print` | builds | +| + `if context.verbose { records.filter { input.names.contains($0.recordName) } }` | builds | +| + plain `} catch { print("error") }` (no pattern) | builds | +| + `} catch CloudKitError.httpErrorWithDetails(statusCode: _, serverErrorCode: _, reason: _) { … }` | builds | +| + `} catch CloudKitError.httpErrorWithDetails(statusCode: 404, serverErrorCode: _, reason: _) { … }` | **crash** | +| + `} catch CloudKitError.httpErrorWithDetails(statusCode: _, serverErrorCode: "X", reason: _) { … }` | **crash** | + +The minimum sufficient ingredient is a **non-wildcard literal at any +associated-value position** in the destructured `catch` pattern. The literal's +type (`Int` vs `String`) and its position do not matter; presence of any +literal does. + +## What was ruled out + +These hypotheses were tested and falsified during bisection: + +- **Tools-version language mode**: crash reproduces with all four combinations + of `swift-tools-version` 6.1/6.2/6.3 across MistKit and MistDemo + `Package.swift`. +- **Multi-arg overload ambiguity at call sites**: the deprecated + `CloudKitService.queryRecords(...) -> [RecordInfo]` overload was removed and + call sites updated; the SIL crash still reproduces with only one + `queryRecords` overload visible to overload resolution. +- **Typed vs untyped throws on the protocol primitive**: per the original + branch experiment commit message, both forms reproduce. +- **`Sendable` conformance on `QueryFilter`/`QuerySort`**: per the same + commit message, toggling did not affect the crash. +- **The body of `queryAllRecords`**: per the same commit message, replacing + the body did not affect the crash. +- **Direct calls to the new generic `queryAllRecords` extension method**: + MistDemo never calls it; bisection of `QueryRecordsPhase.run`'s body shows + the trigger is the `catch` clause itself, not anything traversing the + generic protocol extension. + +## Why MistDemo and not BushelCloud (the sibling example) + +`Examples/BushelCloud` builds cleanly against the same branch. It implements +the new `RecordManaging` primitive but never uses a `catch` clause that +destructures `CloudKitError` with a literal in the pattern. Removing or +softening that single `catch` in MistDemo's `QueryRecordsPhase.run` makes +MistDemo build cleanly as well. + +## Workaround + +Replace the typed-pattern `catch` with a plain `catch` followed by a +`guard case`/`if case` that performs the same destructuring + literal match. +Identical runtime behavior; the SIL the compiler emits is different enough to +sidestep the bug. + +```swift +} catch { + guard case CloudKitError.httpErrorWithDetails(statusCode: 404, _, _) = error else { + throw error + } + print("…") +} +``` + +This workaround is applied in +`Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift` +on this branch. + +## Suggested investigation in the compiler + +The crash is in the C++ `StackNesting::fixNesting` invoked from the +Swift-implemented optimizer's `tryConvertBoxesToStack`. Likely areas to look: + +- SIL emitted for a `catch` clause that produces a `try_apply` whose + unwind/error block performs a `switch_enum_addr` (or equivalent) destructure + of an indirect enum case with at least one literal-pattern match, + particularly when one of the boxes the pass attempts to promote is the + caught error value. +- The combination with the `RecordManaging` protocol witness call (an `async + throws` requirement satisfied by a typed-throws concrete witness) which + produces a witness-thunk in the same function body. + +A minimal isolated reproducer outside MistKit was not produced — the bug +needs both the protocol shape and the typed-pattern catch in scope. The full +project repro is small enough (one branch, ~10 second clean build to crash) +to use directly. diff --git a/Scripts/generate-openapi.sh b/Scripts/generate-openapi.sh index a02b949b..a22afa72 100755 --- a/Scripts/generate-openapi.sh +++ b/Scripts/generate-openapi.sh @@ -11,34 +11,18 @@ echo "🔄 Generating OpenAPI code..." SCRIPT_DIR=$(dirname "$(readlink -f "$0")") PACKAGE_DIR="${SCRIPT_DIR}/.." -# Detect OS and set paths accordingly -if [ "$(uname)" = "Darwin" ]; then - DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" -elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then - DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint" -elif [ "$(uname)" = "Linux" ]; then - DEFAULT_MINT_PATH="/usr/local/bin/mint" -else - echo "Unsupported operating system" - exit 1 +# Put mise-managed tools on PATH (swift-openapi-generator is provisioned via mise.toml) +if command -v mise >/dev/null 2>&1; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi -# Use environment MINT_CMD if set, otherwise use default path -MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} - -export MINT_PATH="$PACKAGE_DIR/.mint" -MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" -MINT_RUN="$MINT_CMD run $MINT_ARGS" - pushd $PACKAGE_DIR -$MINT_CMD bootstrap -m Mintfile -# Run the OpenAPI generator via Mint -$MINT_RUN swift-openapi-generator generate \ - --output-directory Sources/MistKit/Generated \ +swift-openapi-generator generate \ + --output-directory Sources/MistKitOpenAPI \ --config openapi-generator-config.yaml \ openapi.yaml popd -echo "✅ OpenAPI code generation complete!" \ No newline at end of file +echo "✅ OpenAPI code generation complete!" diff --git a/Scripts/header.sh b/Scripts/header.sh index 3b05882e..809f88ac 100755 --- a/Scripts/header.sh +++ b/Scripts/header.sh @@ -34,8 +34,9 @@ if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$packa usage fi -# Define the header template -header_template="// +# Define the header template using a heredoc +read -r -d '' header_template <<'EOF' +// // %s // %s // @@ -44,7 +45,7 @@ header_template="// // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -54,7 +55,7 @@ header_template="// // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -62,26 +63,34 @@ header_template="// // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR // OTHER DEALINGS IN THE SOFTWARE. -//" +// +EOF # Loop through each Swift file in the specified directory and subdirectories find "$directory" -type f -name "*.swift" | while read -r file; do - # Skip files in the Generated directory - if [[ "$file" == *"/Generated/"* ]]; then - echo "Skipping $file (generated file)" - continue - fi - - # Check if the first line is the swift-format-ignore indicator - first_line=$(head -n 1 "$file") - if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + # Skip files carrying `// swift-format-ignore-file` anywhere in the leading + # comment block. This is the opt-out used by generated files (e.g. + # swift-openapi-generator emits it via `additionalFileComments`) and lets + # them sit anywhere in the tree without needing a path-based exclusion. + if awk ' + /^\/\/[[:space:]]*swift-format-ignore-file[[:space:]]*$/ { found = 1; exit } + /^[[:space:]]*$/ || /^\/\// { next } + { exit } + END { exit !found } + ' "$file"; then echo "Skipping $file due to swift-format-ignore directive." continue fi # Create the header with the current filename - filename=$(basename "$file") - header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + # Escape % characters in user-provided values to prevent format specifier injection + filename=$(basename "$file" | sed 's/%/%%/g') + package_safe=$(printf '%s' "$package" | sed 's/%/%%/g') + creator_safe=$(printf '%s' "$creator" | sed 's/%/%%/g') + year_safe=$(printf '%s' "$year" | sed 's/%/%%/g') + company_safe=$(printf '%s' "$company" | sed 's/%/%%/g') + + header=$(printf "$header_template" "$filename" "$package_safe" "$creator_safe" "$year_safe" "$company_safe") # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" awk ' @@ -96,9 +105,9 @@ find "$directory" -type f -name "*.swift" | while read -r file; do # Add the header to the cleaned file (echo "$header"; echo; cat temp_file) > "$file" - + # Remove the temporary file rm temp_file done -echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." \ No newline at end of file +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Scripts/lint.sh b/Scripts/lint.sh index eafa35f2..f110801d 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -6,11 +6,7 @@ ERRORS=0 run_command() { - if [ "$LINT_MODE" = "STRICT" ]; then - "$@" || ERRORS=$((ERRORS + 1)) - else - "$@" - fi + "$@" || ERRORS=$((ERRORS + 1)) } if [ "$LINT_MODE" = "INSTALL" ]; then @@ -24,51 +20,36 @@ if [ -z "$SRCROOT" ]; then SCRIPT_DIR=$(dirname "$(readlink -f "$0")") PACKAGE_DIR="${SCRIPT_DIR}/.." else - PACKAGE_DIR="${SRCROOT}" + PACKAGE_DIR="${SRCROOT}" fi -# Detect OS and set paths accordingly -if [ "$(uname)" = "Darwin" ]; then - DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" -elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then - DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint" -elif [ "$(uname)" = "Linux" ]; then - DEFAULT_MINT_PATH="/usr/local/bin/mint" -else - echo "Unsupported operating system" - exit 1 +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi -# Use environment MINT_CMD if set, otherwise use default path -MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} - -export MINT_PATH="$PACKAGE_DIR/.mint" -MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" -MINT_RUN="$MINT_CMD run $MINT_ARGS" - if [ "$LINT_MODE" = "NONE" ]; then exit elif [ "$LINT_MODE" = "STRICT" ]; then SWIFTFORMAT_OPTIONS="--configuration .swift-format" SWIFTLINT_OPTIONS="--strict" STRINGSLINT_OPTIONS="--config .strict.stringslint.yml" -else +else SWIFTFORMAT_OPTIONS="--configuration .swift-format" SWIFTLINT_OPTIONS="" STRINGSLINT_OPTIONS="--config .stringslint.yml" fi pushd $PACKAGE_DIR -run_command $MINT_CMD bootstrap -m Mintfile if [ -z "$CI" ]; then - run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests - run_command $MINT_RUN swiftlint --fix + run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command swiftlint --fix fi if [ -z "$FORMAT_ONLY" ]; then - run_command $MINT_RUN swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests - run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command swiftlint lint $SWIFTLINT_OPTIONS # Check for compilation errors run_command swift build --build-tests fi @@ -78,7 +59,7 @@ $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "Bright # Generated files now automatically include ignore directives via OpenAPI generator configuration if [ -z "$CI" ]; then - run_command $MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check + run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check fi popd diff --git a/Scripts/mermaid-to-pptx.py b/Scripts/mermaid-to-pptx.py new file mode 100755 index 00000000..b477041f --- /dev/null +++ b/Scripts/mermaid-to-pptx.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["python-pptx", "lxml"] +# /// +"""Convert Mermaid diagrams to .pptx with native editable shapes.""" + +import argparse +import glob +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +from lxml import etree as lxml_et +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE +from pptx.enum.text import PP_ALIGN +from pptx.util import Inches, Pt + +EXCLUDE_DIRS = frozenset({'_reference', '_test-run', 'node_modules', '.build', 'Derived', '.git', 'build'}) +SLIDE_W = Inches(10) +SLIDE_H = Inches(5.625) # 16:9 +SVG = 'http://www.w3.org/2000/svg' +S = f'{{{SVG}}}' +A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main' +P_NS = 'http://schemas.openxmlformats.org/presentationml/2006/main' + +# Light-theme palette +SLIDE_BG = RGBColor(0xFF, 0xFF, 0xFF) +NODE_FILL = RGBColor(0xF5, 0xF5, 0xF7) +NODE_LINE = RGBColor(0x33, 0x33, 0x33) +CLUSTER_FILL = RGBColor(0xFA, 0xFA, 0xFC) +CLUSTER_LINE = RGBColor(0x99, 0x99, 0x99) +EDGE_COLOR = RGBColor(0x33, 0x33, 0x33) +NODE_TEXT = RGBColor(0x11, 0x11, 0x22) +LABEL_TEXT = RGBColor(0x33, 0x33, 0x33) + +# Sequence-diagram extras +ACTOR_FILL = RGBColor(0xEC, 0xEC, 0xFF) +ACTOR_LINE = RGBColor(0x93, 0x70, 0xDB) +LIFELINE = RGBColor(0x99, 0x99, 0x99) +NOTE_FILL = RGBColor(0xFF, 0xF5, 0xAD) +NOTE_LINE = RGBColor(0xAA, 0xAA, 0x33) +LOOP_LINE = RGBColor(0x93, 0x70, 0xDB) +LABEL_BOX = RGBColor(0xEC, 0xEC, 0xFF) + +FONT_NAME = 'Produkt ExtraLight' +FONT_SIZE = Pt(18) +SEQ_ACTOR_SIZE = Pt(11) +SEQ_MSG_SIZE = Pt(9) +SEQ_NOTE_SIZE = Pt(9) +SEQ_LABEL_SIZE = Pt(9) +EDGE_WIDTH = Pt(2) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def find_repo_root() -> Path: + p = Path(__file__).resolve().parent + while p != p.parent: + if (p / '.git').exists(): + return p + p = p.parent + return Path.cwd() + + +def extract_md_diagrams(md_path: Path) -> list[tuple[str, str]]: + content = md_path.read_text(encoding='utf-8') + return [ + (f'{md_path.stem}-{i:02d}', m.group(1)) + for i, m in enumerate(re.finditer(r'```mermaid\n(.*?)\n```', content, re.DOTALL), 1) + ] + + +def discover(paths: list[Path], repo_root: Path) -> list[tuple[str, str]]: + diagrams: list[tuple[str, str]] = [] + if paths: + for p in paths: + if p.suffix == '.mmd': + diagrams.append((p.stem, p.read_text(encoding='utf-8'))) + elif p.suffix == '.md': + diagrams.extend(extract_md_diagrams(p)) + return diagrams + for dirpath_str, dirnames, filenames in os.walk(repo_root): + dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith('.')) + for fname in sorted(filenames): + fp = Path(dirpath_str) / fname + if fp.suffix == '.mmd': + diagrams.append((fp.stem, fp.read_text(encoding='utf-8'))) + elif fp.suffix == '.md': + diagrams.extend(extract_md_diagrams(fp)) + return diagrams + + +def parse_translate(t: str) -> tuple[float, float]: + m = re.search(r'translate\(([^,)]+)(?:,\s*([^)]+))?\)', t or '') + if not m: + return 0.0, 0.0 + return float(m.group(1)), float(m.group(2) or 0) + + +def get_text(g: lxml_et._Element) -> str: + for fo in g.iter(f'{S}foreignObject'): + text = ''.join(fo.itertext()).strip().replace('\xa0', ' ') + if text: + return text + for t in g.iter(f'{S}text'): + text = ''.join(t.itertext()).strip() + if text: + return text + return '' + + +def get_parent_translate(elem) -> tuple[float, float]: + """Sum translate() transforms from all ancestor elements.""" + tx, ty = 0.0, 0.0 + parent = elem.getparent() + while parent is not None: + t = parent.get('transform', '') + if t: + dx, dy = parse_translate(t) + tx += dx + ty += dy + parent = parent.getparent() + return tx, ty + + +def path_endpoints(d: str) -> tuple[float, float, float, float]: + nums = [float(n) for n in re.findall(r'-?(?:\d+\.?\d*|\.\d+)', d)] + return (nums[0], nums[1], nums[-2], nums[-1]) if len(nums) >= 4 else (0, 0, 0, 0) + + +def _ensure_ln(shape) -> lxml_et._Element | None: + """Return the child of the shape's spPr, creating it if absent.""" + el = shape._element + spPr = el.find(f'{{{P_NS}}}spPr') + if spPr is None: + return None + ln = spPr.find(f'{{{A_NS}}}ln') + if ln is None: + ln = lxml_et.SubElement(spPr, f'{{{A_NS}}}ln') + return ln + + +def add_arrowhead(connector, style: str = 'arrow') -> None: + """Add an arrowhead at the tail end of a connector.""" + ln = _ensure_ln(connector) + if ln is None: + return + for old in ln.findall(f'{{{A_NS}}}tailEnd'): + ln.remove(old) + tail = lxml_et.SubElement(ln, f'{{{A_NS}}}tailEnd') + tail.set('type', style) + tail.set('w', 'med') + tail.set('len', 'med') + + +def set_dashed(shape) -> None: + """Mark a shape's line as dashed (prstDash val='dash').""" + ln = _ensure_ln(shape) + if ln is None: + return + for old in ln.findall(f'{{{A_NS}}}prstDash'): + ln.remove(old) + pd = lxml_et.SubElement(ln, f'{{{A_NS}}}prstDash') + pd.set('val', 'dash') + + +# ── coordinate mapping ──────────────────────────────────────────────────────── + +class CoordMapper: + def __init__(self, vx0: float, vy0: float, vw: float, vh: float, margin: float = 0.05): + margin_w = SLIDE_W * margin + margin_h = SLIDE_H * margin + usable_w = SLIDE_W - 2 * margin_w + usable_h = SLIDE_H - 2 * margin_h + self.scale = min(usable_w / vw, usable_h / vh) + self.ox = margin_w + (usable_w - vw * self.scale) / 2 - vx0 * self.scale + self.oy = margin_h + (usable_h - vh * self.scale) / 2 - vy0 * self.scale + + def pt(self, x: float, y: float) -> tuple[int, int]: + return int(x * self.scale + self.ox), int(y * self.scale + self.oy) + + def dim(self, d: float) -> int: + return int(d * self.scale) + + +# ── rendering ───────────────────────────────────────────────────────────────── + +def render_svg(content: str, out: Path) -> bool: + with tempfile.NamedTemporaryFile(suffix='.mmd', mode='w', delete=False, encoding='utf-8') as f: + f.write(content) + tmp = Path(f.name) + try: + r = subprocess.run( + ['npx', '--yes', '-p', '@mermaid-js/mermaid-cli', 'mmdc', + '-i', str(tmp), '-o', str(out)], + capture_output=True, text=True, + ) + if r.returncode != 0: + print(f' mmdc: {r.stderr.strip()[:200]}', file=sys.stderr) + return False + return out.exists() + finally: + tmp.unlink(missing_ok=True) + + +def _set_run(run, text: str, size, color: RGBColor) -> None: + run.text = text + run.font.name = FONT_NAME + run.font.size = size + run.font.color.rgb = color + run.font.bold = False + + +def _put_text(shape, lines: list[str], size, color: RGBColor, align=PP_ALIGN.CENTER) -> None: + tf = shape.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = Pt(2) + tf.margin_top = tf.margin_bottom = Pt(1) + for i, line in enumerate(lines): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.alignment = align + _set_run(p.add_run(), line, size, color) + + +# ── flowchart renderer ─────────────────────────────────────────────────────── + +def render_flowchart(root, slide, mapper: CoordMapper) -> None: + ns = {'svg': SVG} + + label_by_x: dict[int, str] = {} + for g in root.xpath('.//svg:g[contains(@class,"cluster-label")]', namespaces=ns): + tx, _ = parse_translate(g.get('transform', '')) + ptx, _ = get_parent_translate(g) + text = get_text(g) + if text: + label_by_x[round(tx + ptx)] = text + + def cluster_label(rect_x: float, rect_w: float) -> str: + cx = rect_x + rect_w / 2 + if not label_by_x: + return '' + key = min(label_by_x, key=lambda k: abs(k - cx)) + return label_by_x[key] if abs(key - cx) < rect_w else '' + + cluster_label_boxes: list[tuple[int, int, int, str]] = [] + + for g in root.xpath('.//svg:g[contains(@class,"cluster") and not(contains(@class,"cluster-label"))]', + namespaces=ns): + r = g.find(f'{S}rect') + if r is None: + continue + ptx, pty = get_parent_translate(g) + rx = float(r.get('x', 0)) + ptx + ry = float(r.get('y', 0)) + pty + rw = float(r.get('width', 100)) + rh = float(r.get('height', 50)) + px, py = mapper.pt(rx, ry) + pw, ph = mapper.dim(rw), mapper.dim(rh) + shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, px, py, pw, ph) + shape.fill.solid() + shape.fill.fore_color.rgb = CLUSTER_FILL + shape.line.color.rgb = CLUSTER_LINE + shape.line.width = Pt(1.5) + shape.text_frame.text = '' + label = cluster_label(rx, rw) + if label: + cluster_label_boxes.append((px, py, pw, label)) + + for path in root.xpath('.//svg:path[contains(@class,"flowchart-link")]', namespaces=ns): + d = path.get('d', '') + if not d: + continue + x1, y1, x2, y2 = path_endpoints(d) + px1, py1 = mapper.pt(x1, y1) + px2, py2 = mapper.pt(x2, y2) + conn = slide.shapes.add_connector(MSO_CONNECTOR_TYPE.STRAIGHT, px1, py1, px2, py2) + conn.line.color.rgb = EDGE_COLOR + conn.line.width = EDGE_WIDTH + add_arrowhead(conn) + + for g in root.xpath('.//svg:g[contains(@class,"node") and contains(@class,"default")]', + namespaces=ns): + cx, cy = parse_translate(g.get('transform', '')) + ptx, pty = get_parent_translate(g) + cx += ptx + cy += pty + shape_type = MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE + w, h = 100.0, 40.0 + for child in g: + tag = child.tag.replace(f'{{{SVG}}}', '') + if tag == 'rect': + w = float(child.get('width', 100)) + h = float(child.get('height', 40)) + break + elif tag == 'polygon': + pts = [tuple(map(float, p.split(','))) for p in child.get('points', '').split() if ',' in p] + if pts: + xs, ys = zip(*pts) + w, h = max(xs) - min(xs), max(ys) - min(ys) + shape_type = MSO_AUTO_SHAPE_TYPE.DIAMOND + break + elif tag in ('circle', 'ellipse'): + r = float(child.get('r', 20)) + w = float(child.get('rx', r)) * 2 + h = float(child.get('ry', r)) * 2 + shape_type = MSO_AUTO_SHAPE_TYPE.OVAL + break + + px, py = mapper.pt(cx - w / 2, cy - h / 2) + pw, ph = mapper.dim(w), mapper.dim(h) + shape = slide.shapes.add_shape(shape_type, px, py, pw, ph) + shape.fill.solid() + shape.fill.fore_color.rgb = NODE_FILL + shape.line.color.rgb = NODE_LINE + shape.line.width = Pt(1.5) + _put_text(shape, [get_text(g)], FONT_SIZE, NODE_TEXT) + + label_h = int(Pt(20)) + for lpx, lpy, lpw, text in cluster_label_boxes: + tb = slide.shapes.add_textbox(lpx, lpy, lpw, label_h) + tf = tb.text_frame + tf.word_wrap = False + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.CENTER + _set_run(p.add_run(), text, Pt(13), LABEL_TEXT) + + +# ── sequence-diagram renderer ──────────────────────────────────────────────── + +def _line_coords(line) -> tuple[float, float, float, float]: + return (float(line.get('x1', 0)), float(line.get('y1', 0)), + float(line.get('x2', 0)), float(line.get('y2', 0))) + + +def _text_xy(t) -> tuple[float, float]: + return float(t.get('x', 0)), float(t.get('y', 0)) + + +def _text_content(t) -> str: + return ''.join(t.itertext()).strip() + + +def render_sequence(root, slide, mapper: CoordMapper) -> None: + ns = {'svg': SVG} + + # 1. Control-structure frames (alt / loop / opt) — perimeter rect, dividers, labels + for g in root.xpath('.//svg:g[@data-et="control-structure"]', namespaces=ns): + perim, dividers = [], [] + for line in g.findall(f'{S}line'): + if 'loopLine' not in (line.get('class') or ''): + continue + (perim if 'dasharray' not in (line.get('style') or '') else dividers).append(line) + if len(perim) >= 4: + xs, ys = [], [] + for l in perim: + x1, y1, x2, y2 = _line_coords(l) + xs += [x1, x2]; ys += [y1, y2] + x0, y0, x1_, y1_ = min(xs), min(ys), max(xs), max(ys) + px, py = mapper.pt(x0, y0) + pw, ph = mapper.dim(x1_ - x0), mapper.dim(y1_ - y0) + box = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.RECTANGLE, px, py, pw, ph) + box.fill.background() + box.line.color.rgb = LOOP_LINE + box.line.width = Pt(1) + box.text_frame.text = '' + + for d in dividers: + x1, y1, x2, y2 = _line_coords(d) + px1, py1 = mapper.pt(x1, y1) + px2, py2 = mapper.pt(x2, y2) + ln = slide.shapes.add_connector(MSO_CONNECTOR_TYPE.STRAIGHT, px1, py1, px2, py2) + ln.line.color.rgb = LOOP_LINE + ln.line.width = Pt(0.75) + set_dashed(ln) + + for poly in g.findall(f'{S}polygon'): + if 'labelBox' not in (poly.get('class') or ''): + continue + pts = [tuple(map(float, p.split(','))) for p in (poly.get('points') or '').split() if ',' in p] + if not pts: + continue + xs, ys = zip(*pts) + px, py = mapper.pt(min(xs), min(ys)) + pw, ph = mapper.dim(max(xs) - min(xs)), mapper.dim(max(ys) - min(ys)) + lb = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.RECTANGLE, px, py, pw, ph) + lb.fill.solid() + lb.fill.fore_color.rgb = LABEL_BOX + lb.line.color.rgb = LOOP_LINE + lb.line.width = Pt(0.75) + lb.text_frame.text = '' + + for t in g.findall(f'{S}text'): + cls = t.get('class', '') + if 'labelText' not in cls and 'loopText' not in cls: + continue + text = _text_content(t) + if not text: + continue + tx, ty = _text_xy(t) + est_w = max(60.0, 8.0 * len(text)) + est_h = 16.0 + px, py = mapper.pt(tx - est_w / 2, ty - est_h / 2) + pw, ph = mapper.dim(est_w), mapper.dim(est_h) + tb = slide.shapes.add_textbox(px, py, pw, ph) + _put_text(tb, [text], SEQ_LABEL_SIZE, LABEL_TEXT) + + # 2. Notes — rect + grouped noteText lines + for g in root.xpath('.//svg:g[@data-et="note"]', namespaces=ns): + rect = g.find(f'{S}rect') + if rect is None: + continue + rx = float(rect.get('x', 0)) + ry = float(rect.get('y', 0)) + rw = float(rect.get('width', 100)) + rh = float(rect.get('height', 40)) + px, py = mapper.pt(rx, ry) + pw, ph = mapper.dim(rw), mapper.dim(rh) + shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.RECTANGLE, px, py, pw, ph) + shape.fill.solid() + shape.fill.fore_color.rgb = NOTE_FILL + shape.line.color.rgb = NOTE_LINE + shape.line.width = Pt(0.75) + lines = [_text_content(t) for t in g.findall(f'{S}text') if _text_content(t)] + _put_text(shape, lines or [''], SEQ_NOTE_SIZE, NODE_TEXT) + + # 3. Lifelines — thin vertical gray lines between actor top + bottom boxes + for line in root.xpath('.//svg:line[contains(@class,"actor-line")]', namespaces=ns): + x1, y1, x2, y2 = _line_coords(line) + px1, py1 = mapper.pt(x1, y1) + px2, py2 = mapper.pt(x2, y2) + conn = slide.shapes.add_connector(MSO_CONNECTOR_TYPE.STRAIGHT, px1, py1, px2, py2) + conn.line.color.rgb = LIFELINE + conn.line.width = Pt(0.5) + + # 4. Actor boxes (both top and bottom) — pair each rect with its sibling text + for rect in root.xpath('.//svg:rect[contains(@class,"actor")]', namespaces=ns): + cls = rect.get('class', '') + if 'actor-top' not in cls and 'actor-bottom' not in cls: + continue + rx = float(rect.get('x', 0)) + ry = float(rect.get('y', 0)) + rw = float(rect.get('width', 100)) + rh = float(rect.get('height', 40)) + px, py = mapper.pt(rx, ry) + pw, ph = mapper.dim(rw), mapper.dim(rh) + shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, px, py, pw, ph) + shape.fill.solid() + shape.fill.fore_color.rgb = ACTOR_FILL + shape.line.color.rgb = ACTOR_LINE + shape.line.width = Pt(1) + + parent = rect.getparent() + label = '' + if parent is not None: + for t in parent.findall(f'{S}text'): + if 'actor-box' in (t.get('class') or ''): + label = _text_content(t) + break + _put_text(shape, [label], SEQ_ACTOR_SIZE, NODE_TEXT) + + # 5. Messages — connectors with arrowheads (solid messageLine0 / dashed messageLine1) + for line in root.xpath('.//svg:line[contains(@class,"messageLine")]', namespaces=ns): + cls = line.get('class', '') + x1, y1, x2, y2 = _line_coords(line) + px1, py1 = mapper.pt(x1, y1) + px2, py2 = mapper.pt(x2, y2) + conn = slide.shapes.add_connector(MSO_CONNECTOR_TYPE.STRAIGHT, px1, py1, px2, py2) + conn.line.color.rgb = EDGE_COLOR + conn.line.width = Pt(1.25) + if 'messageLine1' in cls: + set_dashed(conn) + add_arrowhead(conn) + + # 6. Message text labels + for t in root.xpath('.//svg:text[contains(@class,"messageText")]', namespaces=ns): + text = _text_content(t) + if not text: + continue + tx, ty = _text_xy(t) + est_w = max(80.0, 7.5 * len(text)) + est_h = 18.0 + px, py = mapper.pt(tx - est_w / 2, ty - est_h / 2) + pw, ph = mapper.dim(est_w), mapper.dim(est_h) + tb = slide.shapes.add_textbox(px, py, pw, ph) + _put_text(tb, [text], SEQ_MSG_SIZE, LABEL_TEXT) + + +# ── SVG → pptx ──────────────────────────────────────────────────────────────── + +def diagram_kind(root) -> str: + role = (root.get('aria-roledescription') or '').lower() + if 'sequence' in role: + return 'sequence' + return 'flowchart' + + +def svg_to_pptx(svg_path: Path, out_pptx: Path) -> None: + tree = lxml_et.parse(svg_path) + root = tree.getroot() + + vb = (root.get('viewBox') or '').split() + vx0, vy0, vw, vh = (float(v) for v in vb) if len(vb) == 4 else (0, 0, 800, 600) + mapper = CoordMapper(vx0, vy0, vw, vh) + + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + blank = next((l for l in prs.slide_layouts if l.name == 'Blank'), prs.slide_layouts[6]) + slide = prs.slides.add_slide(blank) + bg = slide.background + bg.fill.solid() + bg.fill.fore_color.rgb = SLIDE_BG + + kind = diagram_kind(root) + if kind == 'sequence': + render_sequence(root, slide, mapper) + else: + render_flowchart(root, slide, mapper) + + out_pptx.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(out_pptx)) + + +# ── main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('sources', nargs='*', help='Files or globs (default: whole repo)') + ap.add_argument('--list', action='store_true', help='Dry-run: list diagrams only') + args = ap.parse_args() + + repo_root = find_repo_root() + out_dir = repo_root / 'build' / 'pptx' + + paths: list[Path] = [] + for s in args.sources: + expanded = glob.glob(s, recursive=True) + paths.extend(Path(p) for p in (expanded or [s])) + + diagrams = discover(paths, repo_root) + if not diagrams: + print('No Mermaid diagrams found.') + return + + if args.list: + print(f'Found {len(diagrams)} diagram(s):') + for stem, _ in diagrams: + print(f' → {out_dir / stem}.pptx') + return + + ok = 0 + with tempfile.TemporaryDirectory() as tmp_dir: + for stem, content in diagrams: + out_pptx = out_dir / f'{stem}.pptx' + tmp_svg = Path(tmp_dir) / f'{stem}.svg' + print(f' {stem}', end=' ... ', flush=True) + if render_svg(content, tmp_svg): + try: + svg_to_pptx(tmp_svg, out_pptx) + print('✓') + ok += 1 + except Exception as e: + print(f'✗ ({e})') + else: + print('✗ (render failed)') + + print(f'\n{ok}/{len(diagrams)} files written to {out_dir}') + + +if __name__ == '__main__': + main() diff --git a/Sources/MistKit/Authentication/APICredentials.swift b/Sources/MistKit/Authentication/APICredentials.swift new file mode 100644 index 00000000..4fe31259 --- /dev/null +++ b/Sources/MistKit/Authentication/APICredentials.swift @@ -0,0 +1,50 @@ +// +// APICredentials.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// API-token credentials, optionally augmented with a web-auth token for +/// user-context routes. +/// +/// - `apiToken` alone is sufficient for read access against the public +/// database. +/// - `webAuthToken` is required for any route that operates as a specific +/// user — that includes every user-identity endpoint (`fetchCaller`, +/// `lookupUsersByEmail`, …) and any write/read against the private or +/// shared databases. +public struct APICredentials: Sendable { + /// CloudKit API token issued in CloudKit Dashboard for this container. + public let apiToken: String + /// User-context web-auth token; required for private/shared databases and user-identity routes. + public let webAuthToken: String? + + /// Construct API credentials, optionally with a web-auth token for user-context routes. + public init(apiToken: String, webAuthToken: String? = nil) { + self.apiToken = apiToken + self.webAuthToken = webAuthToken + } +} diff --git a/Sources/MistKit/Authentication/APITokenAuthenticator.swift b/Sources/MistKit/Authentication/APITokenAuthenticator.swift new file mode 100644 index 00000000..69cb0cea --- /dev/null +++ b/Sources/MistKit/Authentication/APITokenAuthenticator.swift @@ -0,0 +1,91 @@ +// +// APITokenAuthenticator.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import HTTPTypes +public import OpenAPIRuntime + +/// CloudKit API-token authentication: appends `ckAPIToken=...` as a query item. +/// +/// Suitable for container-level access to the public database. Construct via +/// the throwing initializer so that an invalid token format is rejected before +/// the value can be used to authenticate a request. +public struct APITokenAuthenticator: Authenticator { + private struct WireFormat: Codable { + let token: String + } + + /// Stable storage key (`"api-token"`). + public static let storageKey: String = "api-token" + + /// The 64-character hex CloudKit API token from Apple Developer Console. + public let token: String + + /// Identifier derived from the first 8 characters of the token so that + /// distinct tokens can be persisted side by side. + public var defaultStorageIdentifier: String { + "api-\(token.prefix(8))" + } + + /// Creates an authenticator from an API token string. + /// - Parameter token: The CloudKit API token. + /// - Throws: `TokenManagerError.invalidCredentials` if the token is empty + /// or doesn't match the expected 64-character hex format. + public init(token: String) throws(TokenManagerError) { + guard !token.isEmpty else { + throw TokenManagerError.invalidCredentials(.apiTokenEmpty) + } + let regex = NSRegularExpression.apiTokenRegex + guard !regex.matches(in: token).isEmpty else { + throw TokenManagerError.invalidCredentials(.apiTokenInvalidFormat) + } + self.token = token + } + + /// Reconstructs an `APITokenAuthenticator` from data previously produced + /// by `encoded()`. Re-runs format validation, so a corrupted or stale + /// payload throws `TokenManagerError.invalidCredentials`. + public init(decoding data: Data) throws { + let wire = try JSONDecoder().decode(WireFormat.self, from: data) + try self.init(token: wire.token) + } + + /// Appends `ckAPIToken=` as a query item on the outgoing request. + public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? + ) async throws { + request.appendQueryItems([URLQueryItem(name: "ckAPIToken", value: token)]) + } + + /// JSON-encodes the API token for persistence by `TokenStorage`. + public func encoded() throws -> Data { + try JSONEncoder().encode(WireFormat(token: token)) + } +} diff --git a/Sources/MistKit/Authentication/APITokenManager.swift b/Sources/MistKit/Authentication/APITokenManager.swift index 8744a118..4660c044 100644 --- a/Sources/MistKit/Authentication/APITokenManager.swift +++ b/Sources/MistKit/Authentication/APITokenManager.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -29,71 +29,48 @@ import Foundation -/// Token manager for simple API token authentication -/// Provides container-level access to CloudKit Web Services +/// Token manager for simple API token authentication. +/// Provides container-level access to CloudKit Web Services. public final class APITokenManager: TokenManager, Sendable { private let apiToken: String - private let credentials: TokenCredentials // MARK: - TokenManager Protocol - /// Indicates whether valid credentials are currently available + /// Indicates whether valid credentials are currently available. public var hasCredentials: Bool { get async { !apiToken.isEmpty } } - /// Creates a new API token manager - /// - Parameter apiToken: The CloudKit API token from Apple Developer Console + /// Creates a new API token manager. + /// - Parameter apiToken: The CloudKit API token from Apple Developer Console. public init(apiToken: String) { self.apiToken = apiToken - self.credentials = TokenCredentials.apiToken(apiToken) } - /// Validates the stored credentials for format and completeness - /// - Returns: true if credentials are valid, false otherwise - /// - Throws: TokenManagerError if credentials are invalid + /// Validates the stored credentials for format and completeness. public func validateCredentials() async throws(TokenManagerError) -> Bool { - try Self.validateAPITokenFormat(apiToken) + _ = try APITokenAuthenticator(token: apiToken) return true } - /// Retrieves the current credentials for authentication - /// - Returns: The current token credentials, or nil if not available - /// - Throws: TokenManagerError if credentials are invalid - public func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - // Validate first - _ = try await validateCredentials() - return credentials + /// Returns the API-token authenticator, after validation. + public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try APITokenAuthenticator(token: apiToken) } } // MARK: - Additional API Token Methods extension APITokenManager { - /// The API token value + /// The API token value. public var token: String { apiToken } - /// Returns true if the token appears to be in valid format + /// Returns true if the token appears to be in a valid format. public var isValidFormat: Bool { - do { - try Self.validateAPITokenFormat(apiToken) - return true - } catch { - return false - } - } - - /// Creates credentials with additional metadata - /// - Parameter metadata: Additional metadata to include - /// - Returns: TokenCredentials with metadata - public func credentialsWithMetadata(_ metadata: [String: String]) -> TokenCredentials { - TokenCredentials( - method: .apiToken(apiToken), - metadata: metadata - ) + (try? APITokenAuthenticator(token: apiToken)) != nil } } diff --git a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift index 6c8ee0b2..f54be88f 100644 --- a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift +++ b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,101 +27,38 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation +internal import Logging // MARK: - Transition Methods extension AdaptiveTokenManager { - /// Current authentication mode - public var authenticationMode: AuthenticationMode { - webAuthToken != nil ? .webAuthenticated : .apiOnly - } - - /// Returns true if currently supports user-specific operations - public var supportsUserOperations: Bool { - webAuthToken != nil - } - - /// Returns the current API token - public var currentAPIToken: String { - apiToken - } - - /// Returns the current web auth token (if any) - public var currentWebAuthToken: String? { - webAuthToken - } - - /// Upgrades to web authentication by adding a web auth token - /// - Parameter webAuthToken: The web authentication token from CloudKit JS - /// - Returns: New credentials with web authentication - /// - Throws: TokenManagerError if the web token is invalid - public func upgradeToWebAuthentication(webAuthToken: String) async throws(TokenManagerError) - -> TokenCredentials - { - guard !webAuthToken.isEmpty else { - throw TokenManagerError.invalidCredentials(.webAuthTokenEmpty) - } - - guard webAuthToken.count >= 10 else { - throw TokenManagerError.invalidCredentials(.webAuthTokenTooShort) - } - + /// Upgrades to web authentication by adding a web auth token. + /// - Parameter webAuthToken: The web authentication token from CloudKit JS. + /// - Returns: The web-auth authenticator that will be used for subsequent + /// requests. + /// - Throws: `TokenManagerError` if the web token is invalid. + @discardableResult + public func upgradeToWebAuthentication( + webAuthToken: String + ) async throws(TokenManagerError) -> WebAuthTokenAuthenticator { + let authenticator = try WebAuthTokenAuthenticator( + apiToken: apiToken, + webAuthToken: webAuthToken + ) self.webAuthToken = webAuthToken - // Mode changed to web authentication - - // Store credentials if storage is available if let storage = storage { - guard let credentials = try await getCurrentCredentials() else { - throw TokenManagerError.internalError(.failedCredentialRetrievalAfterUpgrade) - } do { - try await storage.store(credentials, identifier: apiToken) + try await storage.store(authenticator, identifier: apiToken) } catch { - // Don't fail silently - log the storage error but continue with the upgrade - // This ensures the authentication upgrade succeeds even if storage fails - MistKitLogger.logWarning( - "Failed to store credentials after upgrade: \(error.localizedDescription)", - logger: MistKitLogger.auth + // Don't fail the upgrade if storage fails — just log. + Logger(subsystem: .auth).warning( + "Failed to store credentials after upgrade: \(error.localizedDescription)" ) - // Could also throw here if storage failure should be fatal: - // throw TokenManagerError.internalError( - // reason: "Failed to store credentials: \(error.localizedDescription)" - // ) } } - guard let finalCredentials = try await getCurrentCredentials() else { - throw TokenManagerError.internalError(.failedCredentialRetrievalAfterUpgrade) - } - return finalCredentials - } - - /// Downgrades to API-only authentication (removes web auth token) - /// - Returns: New credentials with API-only authentication - public func downgradeToAPIOnly() async throws(TokenManagerError) -> TokenCredentials { - self.webAuthToken = nil - - // Mode changed to API-only - - guard let finalCredentials = try await getCurrentCredentials() else { - throw TokenManagerError.internalError(.failedCredentialRetrievalAfterDowngrade) - } - return finalCredentials - } - - /// Updates the web auth token (for token refresh scenarios) - /// - Parameter newWebAuthToken: The new web authentication token - /// - Returns: Updated credentials - /// - Throws: TokenManagerError if not in web auth mode or token is invalid - public func updateWebAuthToken(_ newWebAuthToken: String) async throws(TokenManagerError) - -> TokenCredentials - { - guard webAuthToken != nil else { - throw TokenManagerError.invalidCredentials(.authenticationModeMismatch) - } - - return try await upgradeToWebAuthentication(webAuthToken: newWebAuthToken) + return authenticator } } diff --git a/Sources/MistKit/Authentication/AdaptiveTokenManager.swift b/Sources/MistKit/Authentication/AdaptiveTokenManager.swift index 73bc66ec..6a6e5311 100644 --- a/Sources/MistKit/Authentication/AdaptiveTokenManager.swift +++ b/Sources/MistKit/Authentication/AdaptiveTokenManager.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -29,9 +29,12 @@ import Foundation -/// Adaptive token manager that can transition between API-only and Web authentication -/// Starts with API token and can be upgraded to include web authentication -/// Supports storage when upgraded to web authentication +/// Adaptive token manager that can transition between API-only and Web authentication. +/// +/// Starts with API token only and can be upgraded to include web authentication. +/// On each request it vends whichever authenticator matches its current state — +/// `APITokenAuthenticator` while API-only, `WebAuthTokenAuthenticator` after +/// upgrade. public actor AdaptiveTokenManager: TokenManager { internal let apiToken: String internal var webAuthToken: String? @@ -40,17 +43,17 @@ public actor AdaptiveTokenManager: TokenManager { // MARK: - TokenManager Protocol - /// Indicates whether valid credentials are currently available + /// Indicates whether valid credentials are currently available. public var hasCredentials: Bool { get async { !apiToken.isEmpty } } - /// Creates an adaptive token manager starting with API token only + /// Creates an adaptive token manager starting with API token only. /// - Parameters: - /// - apiToken: The CloudKit API token - /// - storage: Optional storage for persistence (default: nil for in-memory only) + /// - apiToken: The CloudKit API token. + /// - storage: Optional storage for persistence (default: nil for in-memory only). public init( apiToken: String, storage: (any TokenStorage)? = nil @@ -60,31 +63,21 @@ public actor AdaptiveTokenManager: TokenManager { self.storage = storage } - /// Validates the stored credentials for format and completeness - /// - Returns: true if credentials are valid, false otherwise - /// - Throws: TokenManagerError if credentials are invalid + /// Validates the stored credentials for format and completeness. public func validateCredentials() async throws(TokenManagerError) -> Bool { - // Validate API token using common validation - try Self.validateAPITokenFormat(apiToken) - - // Validate web token if present if let webToken = webAuthToken { - try Self.validateWebAuthTokenFormat(webToken) + _ = try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webToken) + } else { + _ = try APITokenAuthenticator(token: apiToken) } - return true } - /// Retrieves the current credentials for authentication - /// - Returns: The current token credentials, or nil if not available - /// - Throws: TokenManagerError if credentials are invalid - public func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - _ = try await validateCredentials() - + /// Returns the authenticator matching the current authentication mode. + public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { if let webToken = webAuthToken { - return TokenCredentials.webAuthToken(apiToken: apiToken, webToken: webToken) - } else { - return TokenCredentials.apiToken(apiToken) + return try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webToken) } + return try APITokenAuthenticator(token: apiToken) } } diff --git a/Sources/MistKit/Authentication/AuthenticationFailedReason.swift b/Sources/MistKit/Authentication/AuthenticationFailedReason.swift new file mode 100644 index 00000000..e6cfa385 --- /dev/null +++ b/Sources/MistKit/Authentication/AuthenticationFailedReason.swift @@ -0,0 +1,57 @@ +// +// AuthenticationFailedReason.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Specific reasons for authentication failure +public enum AuthenticationFailedReason: Sendable { + /// Server rejected the authentication request + case serverRejected(statusCode: Int, message: String?) + + /// Token generation failed + case tokenGenerationFailed(any Error) + + /// Cryptographic signing failed + case signingFailed(any Error) + + /// A human-readable description of the authentication failure reason + public var description: String { + switch self { + case .serverRejected(let statusCode, let message): + if let message { + return "Server rejected authentication with HTTP \(statusCode): \(message)" + } + return "Server rejected authentication with HTTP \(statusCode)" + case .tokenGenerationFailed(let error): + return "Token generation failed: \(error.localizedDescription)" + case .signingFailed(let error): + return "Cryptographic signing failed: \(error.localizedDescription)" + } + } +} diff --git a/Sources/MistKit/Authentication/AuthenticationMethod.swift b/Sources/MistKit/Authentication/AuthenticationMethod.swift deleted file mode 100644 index ee899621..00000000 --- a/Sources/MistKit/Authentication/AuthenticationMethod.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// AuthenticationMethod.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Represents the different authentication methods supported by CloudKit Web Services -public enum AuthenticationMethod: Sendable, Equatable { - /// Simple API token authentication - case apiToken(String) - - /// API token with web authentication token for user-specific operations - case webAuthToken(apiToken: String, webToken: String) - - /// Server-to-server authentication using ECDSA P-256 private key - case serverToServer(keyID: String, privateKey: Data) -} - -// MARK: - AuthenticationMethod Extensions - -extension AuthenticationMethod { - /// Returns the API token for all authentication methods - public var apiToken: String? { - switch self { - case .apiToken(let token): - return token - case .webAuthToken(let apiToken, _): - return apiToken - case .serverToServer: - return nil - } - } - - /// Returns the web auth token if available - public var webAuthToken: String? { - switch self { - case .apiToken: - return nil - case .webAuthToken(_, let webToken): - return webToken - case .serverToServer: - return nil - } - } - - /// Returns the server-to-server key ID if applicable - public var serverKeyID: String? { - switch self { - case .apiToken, .webAuthToken: - return nil - case .serverToServer(let keyID, _): - return keyID - } - } - - /// Returns the private key data for server-to-server authentication - public var privateKeyData: Data? { - switch self { - case .apiToken, .webAuthToken: - return nil - case .serverToServer(_, let privateKey): - return privateKey - } - } - - /// Returns a string representation of the authentication method type - public var methodType: String { - switch self { - case .apiToken: - return "api-token" - case .webAuthToken: - return "web-auth-token" - case .serverToServer: - return "server-to-server" - } - } -} diff --git a/Sources/MistKit/Authentication/AuthenticationMiddleware.swift b/Sources/MistKit/Authentication/AuthenticationMiddleware.swift new file mode 100644 index 00000000..aae54ab6 --- /dev/null +++ b/Sources/MistKit/Authentication/AuthenticationMiddleware.swift @@ -0,0 +1,55 @@ +// +// AuthenticationMiddleware.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime + +/// Authentication middleware that delegates request mutation to whichever +/// `Authenticator` the `TokenManager` currently vends. +internal struct AuthenticationMiddleware: ClientMiddleware { + internal let tokenManager: any TokenManager + + internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + guard let authenticator = try await tokenManager.currentAuthenticator() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) + } + + var modifiedRequest = request + var modifiedBody = body + try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) + return try await next(modifiedRequest, modifiedBody, baseURL) + } +} diff --git a/Sources/MistKit/Authentication/AuthenticationMode.swift b/Sources/MistKit/Authentication/AuthenticationMode.swift deleted file mode 100644 index df9c3dcb..00000000 --- a/Sources/MistKit/Authentication/AuthenticationMode.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// AuthenticationMode.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Represents the current authentication mode -public enum AuthenticationMode: Sendable, Equatable { - /// API token only - container-level access - case apiOnly - - /// API + Web token - user-specific access - case webAuthenticated - - /// Human-readable description - public var description: String { - switch self { - case .apiOnly: - return "API Token Only (Container Access)" - case .webAuthenticated: - return "Web Authenticated (User Access)" - } - } - - /// Returns true if this mode supports user operations - public var supportsUserOperations: Bool { - switch self { - case .apiOnly: - return false - case .webAuthenticated: - return true - } - } -} diff --git a/Sources/MistKit/Authentication/Authenticator.swift b/Sources/MistKit/Authentication/Authenticator.swift new file mode 100644 index 00000000..a2db68da --- /dev/null +++ b/Sources/MistKit/Authentication/Authenticator.swift @@ -0,0 +1,99 @@ +// +// Authenticator.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import HTTPTypes +public import OpenAPIRuntime + +/// A value that knows how to apply a particular CloudKit authentication scheme +/// to an outgoing HTTP request. +/// +/// Concrete authenticators (`APITokenAuthenticator`, `WebAuthTokenAuthenticator`, +/// `ServerToServerAuthenticator`) own both the credential payload and the rules +/// for attaching it to a request. The `AuthenticationMiddleware` simply asks the +/// current authenticator to apply itself; new authentication schemes can be +/// added without modifying the middleware. +/// +/// `Authenticator` deliberately does not inherit `Equatable` or `Codable`: +/// either would impose a `Self` requirement and prevent its use as +/// `any Authenticator`, which storage and `TokenManager.currentAuthenticator()` +/// rely on. Hand-rolled `encoded()` / `init(decoding:)` keep on-disk format +/// decisions next to the type's invariants. +public protocol Authenticator: Sendable { + /// Stable string identifier for routing decoded data back to the right + /// concrete type. Storage stores authenticators as `[storageKey: Data]`. + static var storageKey: String { get } + + /// Identifier used by storage when the caller doesn't supply one. + /// + /// Defaults to `Self.storageKey`. Concrete types override to provide a + /// richer identifier (e.g. one derived from a token prefix or key ID), + /// allowing multiple authenticators of the same type to coexist in + /// storage under distinct keys. + var defaultStorageIdentifier: String { get } + + /// Reconstructs the authenticator from previously-encoded data. + init(decoding data: Data) throws + + /// Attaches this credential to the given HTTP request. + /// + /// - Parameters: + /// - request: The request to mutate (typically by adding query items + /// or headers). + /// - body: The request body. May be reassigned — for example, + /// `ServerToServerAuthenticator` consumes the body to compute a + /// signature and replaces it with a buffered copy so downstream + /// middleware sees the same bytes. + /// - Throws: An error if the credential cannot be applied — for example, + /// `OpenAPIRuntime` errors when buffering the request body fails or + /// exceeds an authenticator-specific size limit. + func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? + ) async throws + + /// Serializes this authenticator's payload for persistence. + /// + /// - Warning: The returned data may contain sensitive credential material + /// (API tokens, web auth tokens, raw P-256 private keys). Implementors + /// of `TokenStorage` are responsible for storing it securely — + /// typically encrypted at rest with appropriate ACLs. + /// `InMemoryTokenStorage` is suitable only for development and testing; + /// production deployments should provide a `TokenStorage` backed by + /// Keychain, a KMS, or an equivalent secret store. + func encoded() throws -> Data +} + +extension Authenticator { + /// Default implementation: returns `Self.storageKey`. Override on the + /// concrete type when a richer per-instance identifier is appropriate. + public var defaultStorageIdentifier: String { + Self.storageKey + } +} diff --git a/Sources/MistKit/Authentication/CharacterMapEncoder.swift b/Sources/MistKit/Authentication/CharacterMapEncoder.swift index 761e5224..43d5a4ee 100644 --- a/Sources/MistKit/Authentication/CharacterMapEncoder.swift +++ b/Sources/MistKit/Authentication/CharacterMapEncoder.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// A token encoder that replaces specific characters with URL-encoded equivalents internal struct CharacterMapEncoder: Sendable { diff --git a/Sources/MistKit/Authentication/CredentialAvailability.swift b/Sources/MistKit/Authentication/CredentialAvailability.swift new file mode 100644 index 00000000..a5d8eb2d --- /dev/null +++ b/Sources/MistKit/Authentication/CredentialAvailability.swift @@ -0,0 +1,46 @@ +// +// CredentialAvailability.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a credential set was missing when the dispatcher tried to satisfy +/// a request. +/// +/// Attached to `CloudKitError.missingCredentials(_:availability:reason:)` so +/// callers can distinguish a misconfiguration ("no credentials at all") from +/// a deliberate `PublicAuthPreference.requires(...)` that couldn't be +/// satisfied ("we have web-auth but the caller required server-to-server"). +public enum CredentialAvailability: Sendable, Hashable { + /// No credential of the type the route needs is configured on + /// `Credentials`. + case notConfigured + + /// A credential type was required by `PublicAuthPreference.requires(_:)` + /// but is not configured. The dispatcher refuses to silently substitute + /// the other credential set. + case preferenceRequired +} diff --git a/Sources/MistKit/Authentication/Credentials+TokenManager.swift b/Sources/MistKit/Authentication/Credentials+TokenManager.swift new file mode 100644 index 00000000..d7267d8b --- /dev/null +++ b/Sources/MistKit/Authentication/Credentials+TokenManager.swift @@ -0,0 +1,179 @@ +// +// Credentials+TokenManager.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Credentials { + /// Resolve the appropriate token manager for an outgoing request. + /// + /// The signing choice is encoded in `database`: + /// - `.public(let auth)` consults `auth` and the populated credential sets + /// per the table below. + /// - `.private` / `.shared` always use web-auth — CloudKit rejects + /// server-to-server signing on those scopes — and require + /// `apiAuth.webAuthToken`. + /// + /// Resolution for `.public(let auth)`: + /// - `auth.required` + mode's creds present → use `auth.mode`. + /// - `auth.required` + mode's creds absent → throw `.preferenceRequired`. + /// - `auth.prefers` + mode's creds present → use `auth.mode`. + /// - `auth.prefers` + mode's creds absent → fall back to the other mode. + /// - `auth.prefers` + neither mode configured → throw `.notConfigured`. + /// + /// - Throws: `CloudKitError.missingCredentials` when no populated credential + /// set can satisfy the requested combination, + /// `CloudKitError.invalidPrivateKey` when a `.file(path:)` PEM cannot be + /// read, or any error from `ServerToServerAuthManager.init` when the PEM + /// is malformed. + internal func makeTokenManager( + for database: Database + ) throws -> any TokenManager { + switch database { + case .public(let auth): + return try makePublicTokenManager(auth: auth) + case .private, .shared: + return try makePrivateOrSharedTokenManager(database) + } + } + + private func makePublicTokenManager( + auth: PublicAuthPreference + ) throws -> any TokenManager { + switch auth.mode { + case .serverToServer: + return try makePublicWithS2SPreference(auth: auth) + case .webAuth: + return try makePublicWithWebAuthPreference(auth: auth) + } + } + + private func makePublicWithS2SPreference( + auth: PublicAuthPreference + ) throws -> any TokenManager { + if let s2s = serverToServer { + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + return try makeServerToServerManager(s2s) + } + } + if auth.required { + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .preferenceRequired, + reason: "PublicAuthPreference.requires(.serverToServer) " + + "but no serverToServer credentials are configured" + ) + } + if let api = apiAuth { + return makeAPITokenManager(api) + } + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .notConfigured, + reason: "expected serverToServer or apiAuth credentials" + ) + } + + private func makePublicWithWebAuthPreference( + auth: PublicAuthPreference + ) throws -> any TokenManager { + if let api = apiAuth, let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + if auth.required { + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .preferenceRequired, + reason: "PublicAuthPreference.requires(.webAuth) " + + "but no apiAuth.webAuthToken is configured" + ) + } + if let s2s = serverToServer { + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + return try makeServerToServerManager(s2s) + } + } + if let api = apiAuth { + return makeAPITokenManager(api) + } + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .notConfigured, + reason: "expected apiAuth.webAuthToken or serverToServer credentials" + ) + } + + private func makePrivateOrSharedTokenManager( + _ database: Database + ) throws -> any TokenManager { + guard let api = apiAuth, let webAuthToken = api.webAuthToken else { + throw CloudKitError.missingCredentials( + database: database, + availability: .notConfigured, + reason: + "private and shared databases require apiAuth with a webAuthToken" + ) + } + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + private func makeServerToServerManager( + _ s2s: ServerToServerCredentials + ) throws -> any TokenManager { + let pem: String + do { + pem = try s2s.privateKey.loadPEM() + } catch { + throw CloudKitError.invalidPrivateKey( + path: s2s.privateKey.filePath, + underlying: error + ) + } + return try ServerToServerAuthManager( + keyID: s2s.keyID, + pemString: pem + ) + } + + private func makeAPITokenManager( + _ api: APICredentials + ) -> any TokenManager { + if let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + return APITokenManager(apiToken: api.apiToken) + } +} diff --git a/Sources/MistKit/Authentication/Credentials.swift b/Sources/MistKit/Authentication/Credentials.swift new file mode 100644 index 00000000..caa445cd --- /dev/null +++ b/Sources/MistKit/Authentication/Credentials.swift @@ -0,0 +1,67 @@ +// +// Credentials.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// CloudKit credentials for a `CloudKitService`. +/// +/// Holds either set of authentication material — server-to-server (public +/// database only) and/or API/web-auth (any database). At call time +/// `CloudKitService` picks the appropriate token manager based on the +/// operation's database and whether user-context auth is required. +/// +/// Provide both when a single service must hit public-database routes via +/// server-to-server signing **and** user-context routes via web-auth. +public struct Credentials: Sendable { + /// Server-to-server signing credentials; valid only against the public database. + public let serverToServer: ServerToServerCredentials? + /// API-token credentials; required for private/shared databases and user-context routes. + public let apiAuth: APICredentials? + + /// Construct credentials. + /// + /// At least one of `serverToServer` or `apiAuth` must be non-nil. In debug + /// builds an empty `Credentials` triggers an `assert` so the misconfiguration + /// surfaces during development; in release builds the same misconfiguration + /// throws `CredentialsValidationError.empty` so callers loading credentials + /// from dynamic config (env vars, JSON, keychain) get a typed, recoverable + /// error instead of a crash. + public init( + serverToServer: ServerToServerCredentials? = nil, + apiAuth: APICredentials? = nil + ) throws(CredentialsValidationError) { + assert( + serverToServer != nil || apiAuth != nil, + "Credentials must include at least one of serverToServer or apiAuth" + ) + guard serverToServer != nil || apiAuth != nil else { + throw .empty + } + self.serverToServer = serverToServer + self.apiAuth = apiAuth + } +} diff --git a/Sources/MistKit/Authentication/CredentialsValidationError.swift b/Sources/MistKit/Authentication/CredentialsValidationError.swift new file mode 100644 index 00000000..ea257e26 --- /dev/null +++ b/Sources/MistKit/Authentication/CredentialsValidationError.swift @@ -0,0 +1,44 @@ +// +// CredentialsValidationError.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Construction-time validation errors for `Credentials`. +public enum CredentialsValidationError: LocalizedError, Sendable { + /// `Credentials` was constructed without any populated credential set. + case empty + + /// Human-readable description of the validation failure. + public var errorDescription: String? { + switch self { + case .empty: + return "Credentials must include at least one of serverToServer or apiAuth" + } + } +} diff --git a/Sources/MistKit/Authentication/Data.swift b/Sources/MistKit/Authentication/Data.swift new file mode 100644 index 00000000..afd72909 --- /dev/null +++ b/Sources/MistKit/Authentication/Data.swift @@ -0,0 +1,46 @@ +// +// Data.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import OpenAPIRuntime + +extension Data { + /// Buffers up to `maxBytes` of `body` so it can be both inspected (e.g. + /// signed) and replayed by downstream readers. Reassigns `body` to a fresh + /// `HTTPBody` carrying the collected bytes; returns `nil` (and leaves + /// `body` untouched) when `body` is already `nil`. + internal init?(buffering body: inout HTTPBody?, upTo maxBytes: Int) async throws { + guard let original = body else { + return nil + } + let bytes = try await Data(collecting: original, upTo: maxBytes) + body = HTTPBody(bytes) + self = bytes + } +} diff --git a/Sources/MistKit/Authentication/DependencyResolutionError.swift b/Sources/MistKit/Authentication/DependencyResolutionError.swift deleted file mode 100644 index a7065cb8..00000000 --- a/Sources/MistKit/Authentication/DependencyResolutionError.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// DependencyResolutionError.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -//// -//// DependencyResolutionError.swift -//// MistKit -//// -//// Created by Leo Dion. -//// Copyright © 2025 BrightDigit. -//// -//// Permission is hereby granted, free of charge, to any person -//// obtaining a copy of this software and associated documentation -//// files (the “Software”), to deal in the Software without -//// restriction, including without limitation the rights to use, -//// copy, modify, merge, publish, distribute, sublicense, and/or -//// sell copies of the Software, and to permit persons to whom the -//// Software is furnished to do so, subject to the following -//// conditions: -//// -//// The above copyright notice and this permission notice shall be -//// included in all copies or substantial portions of the Software. -//// -//// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -//// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -//// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -//// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -//// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -//// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -//// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -//// OTHER DEALINGS IN THE SOFTWARE. -//// -// -// public import Foundation -// -///// Errors that can occur during dependency resolution -// public enum DependencyResolutionError: Error, LocalizedError, Sendable { -// case notRegistered(type: String) -// case resolutionFailed(type: String, underlying: any Error) -// -// public var errorDescription: String? { -// switch self { -// case .notRegistered(let type): -// "Dependency not registered: \(type)" -// case .resolutionFailed(let type, let error): -// "Failed to resolve \(type): \(error.localizedDescription)" -// } -// } -// } diff --git a/Sources/MistKit/Utilities/HTTPField.Name+CloudKit.swift b/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift similarity index 93% rename from Sources/MistKit/Utilities/HTTPField.Name+CloudKit.swift rename to Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift index 2555b2d6..124ed774 100644 --- a/Sources/MistKit/Utilities/HTTPField.Name+CloudKit.swift +++ b/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Authentication/HashFunction+CloudKitBodyHash.swift b/Sources/MistKit/Authentication/HashFunction+CloudKitBodyHash.swift new file mode 100644 index 00000000..3fce1df5 --- /dev/null +++ b/Sources/MistKit/Authentication/HashFunction+CloudKitBodyHash.swift @@ -0,0 +1,44 @@ +// +// HashFunction+CloudKitBodyHash.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Crypto +internal import Foundation + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension HashFunction { + /// Returns the base64-encoded hash of the given body, or the empty + /// string when `body` is nil — matching CloudKit Web Services' convention + /// for signing requests with no body. + internal static func cloudKitBodyHash(of body: Data?) -> String { + guard let body else { + return "" + } + return Data(Self.hash(data: body)).base64EncodedString() + } +} diff --git a/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift b/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift deleted file mode 100644 index 3cc64d14..00000000 --- a/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// InMemoryTokenStorage+Convenience.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -// MARK: - Convenience Methods - -extension InMemoryTokenStorage { - /// Stores credentials with automatic identifier based on authentication method - /// - Parameter credentials: The credentials to store - /// - Throws: TokenStorageError if the storage operation fails - public func store(_ credentials: TokenCredentials) async throws { - let identifier: String - - switch credentials.method { - case .apiToken(let token): - identifier = "api-\(token.prefix(8))" - case .webAuthToken(let apiToken, _): - identifier = "web-\(apiToken.prefix(8))" - case .serverToServer(let keyID, _): - identifier = "s2s-\(keyID)" - } - - try await store(credentials, identifier: identifier) - } - - /// Retrieves credentials by authentication method type - /// - Parameter methodType: The authentication method type to search for - /// - Returns: First matching credentials or nil if not found - /// - Throws: TokenStorageError if the retrieval operation fails - public func retrieve(byMethodType methodType: String) async throws(TokenStorageError) - -> TokenCredentials? - { - let identifiers = try await listIdentifiers() - - for identifier in identifiers { - if let credentials = try await retrieve(identifier: identifier), - credentials.methodType == methodType - { - return credentials - } - } - - return nil - } - - /// Lists all credentials grouped by method type - /// - Returns: Dictionary mapping method types to arrays of credentials - public func credentialsByMethodType() async throws -> [String: [TokenCredentials]] { - var result: [String: [TokenCredentials]] = [:] - let identifiers = try await listIdentifiers() - - for identifier in identifiers { - if let credentials = try await retrieve(identifier: identifier) { - let methodType = credentials.methodType - result[methodType, default: []].append(credentials) - } - } - - return result - } -} diff --git a/Sources/MistKit/Authentication/InMemoryTokenStorage.swift b/Sources/MistKit/Authentication/InMemoryTokenStorage.swift index a7e448fa..bfad41f8 100644 --- a/Sources/MistKit/Authentication/InMemoryTokenStorage.swift +++ b/Sources/MistKit/Authentication/InMemoryTokenStorage.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,142 +27,156 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation + +/// Simple in-memory implementation of `TokenStorage` for development and +/// testing. Does not persist data across application restarts. +internal final class InMemoryTokenStorage: TokenStorage, Sendable { + private struct StoredEntry: Sendable { + let storageKey: String + let payload: Data + let expirationTime: Date? + } -/// Simple in-memory implementation of TokenStorage for development and testing -/// This implementation does not persist data across application restarts -public final class InMemoryTokenStorage: TokenStorage, Sendable { - /// Thread-safe storage using actor private actor Storage { - private var credentials: [String: TokenCredentials] = [:] - private var expirationTimes: [String: Date] = [:] + private var entries: [String: StoredEntry] = [:] - func store( - _ tokenCredentials: TokenCredentials, identifier: String?, expirationTime: Date? = nil - ) { - let key = identifier ?? "default" - credentials[key] = tokenCredentials - expirationTimes[key] = expirationTime + func store(_ entry: StoredEntry, identifier: String?) { + entries[identifier ?? "default"] = entry } - func retrieve(identifier: String?) -> TokenCredentials? { + func retrieve(identifier: String?) -> StoredEntry? { let key = identifier ?? "default" - - // Check if token has expired - if let expirationTime = expirationTimes[key], expirationTime <= Date() { - // Token has expired, remove it - credentials.removeValue(forKey: key) - expirationTimes.removeValue(forKey: key) + if let expiration = entries[key]?.expirationTime, expiration <= Date() { + entries.removeValue(forKey: key) return nil } - - return credentials[key] + return entries[key] } func remove(identifier: String?) { - let key = identifier ?? "default" - credentials.removeValue(forKey: key) - expirationTimes.removeValue(forKey: key) + entries.removeValue(forKey: identifier ?? "default") } func listIdentifiers() -> [String] { - // Return all stored identifiers, including expired ones - Array(credentials.keys) + Array(entries.keys) } func clear() { - credentials.removeAll() - expirationTimes.removeAll() + entries.removeAll() } func cleanupExpiredTokens() { let now = Date() - let expiredKeys = expirationTimes.compactMap { key, expirationTime in - expirationTime <= now ? key : nil + entries = entries.filter { _, entry in + guard let expiration = entry.expirationTime else { + return true + } + return expiration > now } + } + } - for key in expiredKeys { - credentials.removeValue(forKey: key) - expirationTimes.removeValue(forKey: key) + /// Routes decoding by `Authenticator.storageKey`. + private static let factories: [String: @Sendable (Data) throws -> any Authenticator] = { + var entries: [String: @Sendable (Data) throws -> any Authenticator] = [ + APITokenAuthenticator.storageKey: { try APITokenAuthenticator(decoding: $0) }, + WebAuthTokenAuthenticator.storageKey: { try WebAuthTokenAuthenticator(decoding: $0) }, + ] + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + entries[ServerToServerAuthenticator.storageKey] = { + try ServerToServerAuthenticator(decoding: $0) } } - } + return entries + }() private let storage = Storage() - /// Returns the number of stored credentials - public var count: Int { + /// Returns the number of stored credentials. + internal var count: Int { get async { - let identifiers = await storage.listIdentifiers() - return identifiers.count + await storage.listIdentifiers().count } } - /// Returns true if the storage is empty - public var isEmpty: Bool { + /// Returns true if the storage is empty. + internal var isEmpty: Bool { get async { - let identifiers = await storage.listIdentifiers() - return identifiers.isEmpty + await storage.listIdentifiers().isEmpty } } - /// Creates a new in-memory token storage - public init() {} + /// Creates a new in-memory token storage. + internal init() {} // MARK: - TokenStorage Protocol - /// Stores credentials in memory using the provided identifier - /// - Parameters: - /// - credentials: The token credentials to store - /// - identifier: Optional identifier for the credentials (uses "default" if nil) - /// - Throws: TokenStorageError if storage operation fails - public func store(_ credentials: TokenCredentials, identifier: String?) - async throws(TokenStorageError) - { - await storage.store(credentials, identifier: identifier, expirationTime: nil) + /// Stores an authenticator under the given identifier (or `"default"` if + /// `nil`), without an expiration time. + internal func store( + _ authenticator: any Authenticator, + identifier: String? + ) async throws(TokenStorageError) { + try await store(authenticator, identifier: identifier, expirationTime: nil) } - /// Stores credentials with expiration time - /// - Parameters: - /// - credentials: The credentials to store - /// - identifier: Optional identifier for the credentials - /// - expirationTime: When the credentials expire - /// - Throws: TokenStorageError if storage operation fails - public func store(_ credentials: TokenCredentials, identifier: String?, expirationTime: Date?) - async throws(TokenStorageError) - { - await storage.store(credentials, identifier: identifier, expirationTime: expirationTime) + /// Stores an authenticator with an expiration time. + internal func store( + _ authenticator: any Authenticator, + identifier: String?, + expirationTime: Date? + ) async throws(TokenStorageError) { + let payload: Data + do { + payload = try authenticator.encoded() + } catch { + throw TokenStorageError.storageFailed(reason: error.localizedDescription) + } + let entry = StoredEntry( + storageKey: type(of: authenticator).storageKey, + payload: payload, + expirationTime: expirationTime + ) + await storage.store(entry, identifier: identifier) } - /// Retrieves credentials from memory using the provided identifier - /// - Parameter identifier: Optional identifier for the credentials (uses "default" if nil) - /// - Returns: The stored credentials, or nil if not found or expired - /// - Throws: TokenStorageError if retrieval operation fails - public func retrieve(identifier: String?) async throws(TokenStorageError) -> TokenCredentials? { - await storage.retrieve(identifier: identifier) + /// Retrieves the authenticator stored under the given identifier, or + /// `nil` if none is stored or the entry has expired. Routes decoding to + /// the correct concrete type via `Authenticator.storageKey`. + internal func retrieve( + identifier: String? + ) async throws(TokenStorageError) -> (any Authenticator)? { + guard let entry = await storage.retrieve(identifier: identifier) else { + return nil + } + guard let factory = Self.factories[entry.storageKey] else { + throw TokenStorageError.corruptedStorage + } + do { + return try factory(entry.payload) + } catch { + throw TokenStorageError.corruptedStorage + } } - /// Removes credentials from memory using the provided identifier - /// - Parameter identifier: Optional identifier for the credentials (uses "default" if nil) - /// - Throws: TokenStorageError if removal operation fails - public func remove(identifier: String?) async throws(TokenStorageError) { + /// Removes the entry stored under the given identifier (no-op if none). + internal func remove(identifier: String?) async throws(TokenStorageError) { await storage.remove(identifier: identifier) } - /// Lists all identifiers currently stored in memory - /// - Returns: Array of identifier strings for all stored credentials - /// - Throws: TokenStorageError if listing operation fails - public func listIdentifiers() async throws(TokenStorageError) -> [String] { + /// Returns every identifier currently in storage, including expired ones. + internal func listIdentifiers() async throws(TokenStorageError) -> [String] { await storage.listIdentifiers() } - /// Clears all stored credentials (useful for testing and development) - public func clear() async { + /// Clears all stored credentials. + internal func clear() async { await storage.clear() } - /// Cleans up expired tokens from storage - public func cleanupExpiredTokens() async { + /// Cleans up expired tokens from storage. + internal func cleanupExpiredTokens() async { await storage.cleanupExpiredTokens() } } diff --git a/Sources/MistKit/Authentication/InternalErrorReason.swift b/Sources/MistKit/Authentication/InternalErrorReason.swift index 1f52d608..5a020c45 100644 --- a/Sources/MistKit/Authentication/InternalErrorReason.swift +++ b/Sources/MistKit/Authentication/InternalErrorReason.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -32,9 +32,6 @@ import Foundation /// Specific reasons for internal errors public enum InternalErrorReason: Sendable { case noCredentialsAvailable - case failedCredentialRetrievalAfterUpgrade - case failedCredentialRetrievalAfterDowngrade - case serverToServerRequiresSpecificManager case serverToServerRequiresPlatformSupport case tokenRefreshFailed(any Error) @@ -43,12 +40,6 @@ public enum InternalErrorReason: Sendable { switch self { case .noCredentialsAvailable: return "No credentials available" - case .failedCredentialRetrievalAfterUpgrade: - return "Failed to get credentials after upgrade" - case .failedCredentialRetrievalAfterDowngrade: - return "Failed to get credentials after downgrade" - case .serverToServerRequiresSpecificManager: - return "Server-to-server credentials require ServerToServerAuthManager" case .serverToServerRequiresPlatformSupport: return "Server-to-server authentication requires macOS 11.0+, iOS 14.0+, tvOS 14.0+, or watchOS 7.0+" diff --git a/Sources/MistKit/Authentication/InvalidCredentialReason.swift b/Sources/MistKit/Authentication/InvalidCredentialReason.swift index 433e8dc6..e1027be2 100644 --- a/Sources/MistKit/Authentication/InvalidCredentialReason.swift +++ b/Sources/MistKit/Authentication/InvalidCredentialReason.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -42,6 +42,7 @@ public enum InvalidCredentialReason: Sendable { case invalidPEMFormat(any Error) case privateKeyParseFailed(any Error) case privateKeyInvalidOrCorrupted(any Error) + case encodedPayloadInvalidBase64 case authenticationModeMismatch case serverToServerOnlySupportsPublicDatabase(String) @@ -70,13 +71,15 @@ public enum InvalidCredentialReason: Sendable { return "Failed to parse private key from PEM string: \(error.localizedDescription)" case .privateKeyInvalidOrCorrupted(let error): return "Private key is invalid or corrupted: \(error.localizedDescription)" + case .encodedPayloadInvalidBase64: + return "Encoded authenticator payload contains invalid base64 data" case .authenticationModeMismatch: return "Cannot update web auth token when not in web authentication mode" case .serverToServerOnlySupportsPublicDatabase(let currentDatabase): return """ Server-to-server authentication only supports the public database. \ Current database: \(currentDatabase). \ - Use MistKitConfiguration.serverToServer() for proper configuration. + Construct CloudKitService with a public database and server-to-server credentials. """ } } diff --git a/Sources/MistKit/Authentication/NetworkErrorReason.swift b/Sources/MistKit/Authentication/NetworkErrorReason.swift new file mode 100644 index 00000000..7b706a38 --- /dev/null +++ b/Sources/MistKit/Authentication/NetworkErrorReason.swift @@ -0,0 +1,68 @@ +// +// NetworkErrorReason.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Specific reasons for network errors during authentication +public enum NetworkErrorReason: Sendable { + /// Request timed out + case timeout + + /// Network connection was lost + case connectionLost + + /// Device is not connected to the internet + case notConnectedToInternet + + /// A URL-level error occurred + case urlError(URLError) + + /// Any other network error + case other(any Error) + + /// A human-readable description of the network error reason + public var description: String { + switch self { + case .timeout: + return "Request timed out" + case .connectionLost: + return "Network connection was lost" + case .notConnectedToInternet: + return "Not connected to the internet" + case .urlError(let error): + return "URL error: \(error.localizedDescription)" + case .other(let error): + return error.localizedDescription + } + } +} diff --git a/Sources/MistKit/Authentication/PrivateKeyMaterial.swift b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift new file mode 100644 index 00000000..21b688bf --- /dev/null +++ b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift @@ -0,0 +1,68 @@ +// +// PrivateKeyMaterial.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Source of a server-to-server private key — either inline PEM or a path to a +/// `.pem` file on disk. +/// +/// Used by `ServerToServerCredentials` to defer reading the private key until +/// the credentials are actually consumed by `CloudKitService`. Inline PEM may +/// contain literal `\n` escape sequences (common when stored in environment +/// variables); `loadPEM()` normalizes them to real newlines. +public enum PrivateKeyMaterial: Sendable { + case raw(String) + case file(path: String) + + /// The on-disk path when this material is `.file(path:)`, otherwise `nil`. + /// + /// Used by `CloudKitError.invalidPrivateKey` to attach a useful diagnostic + /// when `loadPEM()` fails on a missing or unreadable file. + public var filePath: String? { + switch self { + case .raw: + return nil + case .file(let path): + return path + } + } + + /// Resolve the PEM text for this material. + /// + /// - Throws: Any error from the underlying file read when `.file(path:)` is + /// used (e.g. file not found, permission denied). + public func loadPEM() throws -> String { + switch self { + case .raw(let pem): + return pem.replacingOccurrences(of: "\\n", with: "\n") + case .file(let path): + return try String(contentsOfFile: path, encoding: .utf8) + } + } +} diff --git a/Sources/MistKit/Authentication/PublicAuthPreference.swift b/Sources/MistKit/Authentication/PublicAuthPreference.swift new file mode 100644 index 00000000..74845464 --- /dev/null +++ b/Sources/MistKit/Authentication/PublicAuthPreference.swift @@ -0,0 +1,79 @@ +// +// PublicAuthPreference.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Per-call attribution choice for `Database.public` requests. +/// +/// CloudKit's public database accepts two signing methods: +/// server-to-server (key-pair signed, attributed to the developer key) and +/// web-auth (user session token, attributed to the iCloud user). The same +/// server legitimately writes some records as "the app" and others as +/// "this user", so the choice is genuinely per-call. +/// +/// Construct via the static factories — `internal init` keeps the four +/// valid `(mode, required)` combinations the only reachable ones. +/// +/// ```swift +/// // Server-attributed write, fall back to web-auth if S2S isn't configured. +/// service.createRecord(..., database: .public(.prefers(.serverToServer))) +/// +/// // User-attributed write, throw if web-auth credentials aren't configured. +/// service.createRecord(..., database: .public(.requires(.webAuth))) +/// ``` +public struct PublicAuthPreference: Sendable, Hashable { + /// Which signing material to use for a `.public` request. + public enum Mode: Sendable, Hashable { + /// Sign with the server-to-server key pair. Records are attributed to + /// the developer key, not an end user. + case serverToServer + + /// Sign with the user's web-auth token. Records are attributed to the + /// iCloud user that issued the token. + case webAuth + } + + /// The signing material the caller wants. + public let mode: Mode + + /// Whether to throw if `mode`'s credentials aren't configured. + /// + /// - `true` → throw `CloudKitError.missingCredentials(availability: .preferenceRequired)`. + /// - `false` → fall back to the other configured credential set when possible. + public let required: Bool + + /// Prefer the given mode; fall back to the other if it isn't configured. + public static func prefers(_ mode: Mode) -> Self { + .init(mode: mode, required: false) + } + + /// Require the given mode; throw `missingCredentials(.preferenceRequired)` + /// if its credentials aren't configured. + public static func requires(_ mode: Mode) -> Self { + .init(mode: mode, required: true) + } +} diff --git a/Sources/MistKit/Authentication/RequestSignature.swift b/Sources/MistKit/Authentication/RequestSignature.swift index 74a8050f..2d84ee1d 100644 --- a/Sources/MistKit/Authentication/RequestSignature.swift +++ b/Sources/MistKit/Authentication/RequestSignature.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,25 +27,136 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Crypto +internal import Foundation +internal import HTTPTypes /// CloudKit Web Services request signature components -public struct RequestSignature: Sendable { +internal struct RequestSignature: Sendable { /// The key identifier for X-Apple-CloudKit-Request-KeyID header - public let keyID: String + internal let keyID: String - /// The ISO8601 date string for X-Apple-CloudKit-Request-ISO8601Date header - public let date: String + /// The ISO8601 date string for X-Apple-CloudKit-Request-ISO8601Date header. + /// Stored as the exact string that was signed so the wire value cannot drift + /// from the signed payload. + internal let iso8601DateString: String - /// The base64-encoded signature for X-Apple-CloudKit-Request-SignatureV1 header - public let signature: String + /// The DER-encoded ECDSA signature bytes used for the + /// X-Apple-CloudKit-Request-SignatureV1 header. Base64-encoded on demand + /// via `signatureBase64` when building the header value. + internal let signatureDerRepresentation: Data - /// Creates CloudKit request headers from this signature - public var headers: [String: String] { - [ - "X-Apple-CloudKit-Request-KeyID": keyID, - "X-Apple-CloudKit-Request-ISO8601Date": date, - "X-Apple-CloudKit-Request-SignatureV1": signature, - ] + /// The base64-encoded signature value for the + /// X-Apple-CloudKit-Request-SignatureV1 header. + internal var signatureBase64: String { + signatureDerRepresentation.base64EncodedString() } + + /// The CloudKit signature headers in typed form. Merge with + /// `HTTPRequest.headerFields` via `append(contentsOf:)`. + internal var headers: HTTPFields { + var fields = HTTPFields() + fields[.cloudKitRequestKeyID] = keyID + fields[.cloudKitRequestISO8601Date] = iso8601DateString + fields[.cloudKitRequestSignatureV1] = signatureBase64 + return fields + } + + /// Construct a signature from the CloudKit key ID, ISO-8601 date, and DER signature bytes. + internal init( + keyID: String, + iso8601DateString: String, + signatureDerRepresentation: Data + ) { + self.keyID = keyID + self.iso8601DateString = iso8601DateString + self.signatureDerRepresentation = signatureDerRepresentation + } +} + +extension RequestSignature { + // Fallback formatter for OSes that predate `Date.ISO8601FormatStyle`. + // `ISO8601DateFormatter.string(from:)` is documented thread-safe, so a + // shared instance is safe across concurrent signers. + nonisolated(unsafe) fileprivate static let legacyISO8601DateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + return formatter + }() + + /// Signs a CloudKit Web Services request and produces the headers required + /// by the server. + /// + /// - Parameters: + /// - keyID: The CloudKit key identifier from Apple Developer Console. + /// - privateKey: The ECDSA P-256 private key used to sign the payload. + /// - requestBody: The HTTP request body (for POST requests). May be nil. + /// - webServiceSubpath: The CloudKit Web Services URL subpath. + /// - date: The request date. Defaults to `Date()`. + /// - Throws: A `Crypto` error if `P256.Signing.PrivateKey.signature(for:)` + /// fails to produce a signature. + internal init( + keyID: String, + privateKey: P256.Signing.PrivateKey, + requestBody: Data?, + webServiceSubpath: String?, + date: Date = Date() + ) throws { + assert( + webServiceSubpath != nil, + "RequestSignature requires a non-nil webServiceSubpath; HTTPRequest.path was nil" + ) + try self.init( + keyID: keyID, + privateKey: privateKey, + bodyHash: SHA256.cloudKitBodyHash(of: requestBody), + webServiceSubpath: webServiceSubpath ?? "", + iso8601DateString: Self.iso8601String(from: date) + ) + } + + /// Signs a CloudKit Web Services request from pre-computed body hash and + /// date string. Useful when the caller has already formatted those values + /// (e.g. for deterministic testing). + /// + /// - Parameters: + /// - keyID: The CloudKit key identifier from Apple Developer Console. + /// - privateKey: The ECDSA P-256 private key used to sign the payload. + /// - bodyHash: The base64-encoded SHA-256 hash of the request body, or + /// the empty string when no body is present. + /// - webServiceSubpath: The CloudKit Web Services URL subpath. + /// - iso8601DateString: The ISO8601-formatted request date. This exact + /// string is both signed and emitted on the wire — keep them in sync. + /// - Throws: A `Crypto` error if `P256.Signing.PrivateKey.signature(for:)` + /// fails to produce a signature. + internal init( + keyID: String, + privateKey: P256.Signing.PrivateKey, + bodyHash: String, + webServiceSubpath: String, + iso8601DateString: String + ) throws { + let payload = "\(iso8601DateString):\(bodyHash):\(webServiceSubpath)" + let signature = try privateKey.signature(for: Data(payload.utf8)) + + self.init( + keyID: keyID, + iso8601DateString: iso8601DateString, + signatureDerRepresentation: signature.derRepresentation + ) + } + + fileprivate static func iso8601String(from date: Date) -> String { + if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { + return Self.iso8601FormatStyle.format(date) + } + return Self.legacyISO8601DateFormatter.string(from: date) + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension RequestSignature { + // Preferred Sendable formatter for modern OSes. Picked up by + // `iso8601String(from:)` via an `#available` check. + fileprivate static let iso8601FormatStyle = Date.ISO8601FormatStyle() } diff --git a/Sources/MistKit/Authentication/SecureLogging.swift b/Sources/MistKit/Authentication/SecureLogging.swift deleted file mode 100644 index d33b9d13..00000000 --- a/Sources/MistKit/Authentication/SecureLogging.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// SecureLogging.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Utilities for secure logging that masks sensitive information -internal enum SecureLogging { - /// Masks a token by showing only the first few and last few characters - /// - Parameters: - /// - token: The token to mask - /// - prefixLength: Number of characters to show at the beginning (default: 8) - /// - suffixLength: Number of characters to show at the end (default: 4) - /// - maskCharacter: Character to use for masking (default: "*") - /// - Returns: A masked version of the token - internal static func maskToken( - _ token: String, - prefixLength: Int = 8, - suffixLength: Int = 4, - maskCharacter: Character = "*" - ) -> String { - guard token.count > prefixLength + suffixLength else { - return String(repeating: maskCharacter, count: token.count) - } - - let prefix = String(token.prefix(prefixLength)) - let maskCount = token.count - prefixLength - suffixLength - - guard maskCount > suffixLength else { - // Mask is too short to meaningfully separate from suffix — hide all chars after prefix - let remaining = String(repeating: maskCharacter, count: token.count - prefixLength) - return "\(prefix)\(remaining)" - } - - let suffix = String(token.suffix(suffixLength)) - let mask = String(repeating: maskCharacter, count: maskCount) - return "\(prefix)\(mask)\(suffix)" - } - - /// Masks an API token with standard CloudKit format - /// - Parameter apiToken: The API token to mask - /// - Returns: A masked version of the API token - internal static func maskAPIToken(_ apiToken: String) -> String { - maskToken(apiToken, prefixLength: 2, suffixLength: 2) - } - - /// Creates a safe logging string that masks sensitive information - /// - Parameter message: The message to log - /// - Returns: The message as-is (redaction disabled by default, enable with MISTKIT_ENABLE_LOG_REDACTION) - internal static func safeLogMessage(_ message: String) -> String { - // Redaction disabled by default - enable with environment variable if needed - guard ProcessInfo.processInfo.environment["MISTKIT_ENABLE_LOG_REDACTION"] != nil else { - return message - } - - var safeMessage = message - - // Use static regex patterns for better performance - let patterns: [(NSRegularExpression, String)] = [ - // API tokens (64 character hex strings) - (NSRegularExpression.maskApiTokenRegex, "API_TOKEN_REDACTED"), - // Web auth tokens (base64-like strings) - (NSRegularExpression.maskWebAuthTokenRegex, "WEB_AUTH_TOKEN_REDACTED"), - // Key IDs (alphanumeric strings) - (NSRegularExpression.maskKeyIdRegex, "KEY_ID_REDACTED"), - // Generic tokens - (NSRegularExpression.maskGenericTokenRegex, "token=***REDACTED***"), - (NSRegularExpression.maskGenericKeyRegex, "key=***REDACTED***"), - (NSRegularExpression.maskGenericSecretRegex, "secret=***REDACTED***"), - ] - - for (regex, replacement) in patterns { - safeMessage = regex.stringByReplacingMatches( - in: safeMessage, - range: NSRange(location: 0, length: safeMessage.count), - withTemplate: replacement - ) - } - - return safeMessage - } -} - -/// Extension to provide safe logging methods for common types -extension String { - /// Returns a masked API token version of this string - public var maskedAPIToken: String { - SecureLogging.maskAPIToken(self) - } -} diff --git a/Sources/MistKit/Authentication/ServerToServerAuthManager+RequestSigning.swift b/Sources/MistKit/Authentication/ServerToServerAuthManager+RequestSigning.swift deleted file mode 100644 index a99b8714..00000000 --- a/Sources/MistKit/Authentication/ServerToServerAuthManager+RequestSigning.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// ServerToServerAuthManager+RequestSigning.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Crypto -public import Foundation - -// MARK: - Request Signing Methods - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension ServerToServerAuthManager { - /// The key identifier - public var keyIdentifier: String { - keyID - } - - /// Returns the public key for verification purposes - public var publicKey: P256.Signing.PublicKey { - get throws { - try createPrivateKey().publicKey - } - } - - /// Signs a CloudKit Web Services request - /// - Parameters: - /// - requestBody: The HTTP request body (for POST requests) - /// - webServiceURL: The full CloudKit Web Services URL - /// - date: The request date (defaults to current date) - /// - Returns: Signature components for CloudKit headers - /// - Throws: TokenManagerError if signing fails due to invalid key or other errors - public func signRequest( - requestBody: Data?, - webServiceURL: String, - date: Date = Date() - ) throws -> RequestSignature { - // Create the signature payload according to Apple's CloudKit specification: - // [Current Date]:[Base64 Body Hash]:[Web Service URL Subpath] - // Apple requires ISO8601 format without milliseconds (e.g., 2016-01-25T22:15:43Z) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withTimeZone] - let iso8601Date = formatter.string(from: date) - - // Calculate SHA-256 hash of request body, then base64 encode (per Apple docs) - let bodyHash: String - if let requestBody = requestBody { - let hash = SHA256.hash(data: requestBody) - bodyHash = Data(hash).base64EncodedString() - } else { - bodyHash = "" - } - - let signaturePayload = "\(iso8601Date):\(bodyHash):\(webServiceURL)" - let payloadData = Data(signaturePayload.utf8) - - // Create ECDSA signature - let privateKey = try createPrivateKey() - let signature = try privateKey.signature(for: payloadData) - let signatureBase64 = signature.derRepresentation.base64EncodedString() - - return RequestSignature( - keyID: keyID, - date: iso8601Date, - signature: signatureBase64 - ) - } - - /// Creates credentials with additional metadata - /// - Parameter metadata: Additional metadata to include - /// - Returns: TokenCredentials with metadata - /// - Throws: TokenManagerError if credential creation fails - public func credentialsWithMetadata(_ metadata: [String: String]) throws(TokenManagerError) - -> TokenCredentials - { - try TokenCredentials( - method: .serverToServer(keyID: keyID, privateKey: createPrivateKey().rawRepresentation), - metadata: metadata - ) - } - - /// Creates new credentials with rotated key (for key rotation) - /// - Parameter newPrivateKey: The new private key - /// - Returns: New TokenCredentials with updated key - /// - Note: This creates new credentials but doesn't update the manager's internal key - public func credentialsWithRotatedKey(to newPrivateKey: P256.Signing.PrivateKey) - -> TokenCredentials - { - // Note: This would typically require updating the keyID as well in a real rotation - TokenCredentials.serverToServer( - keyID: keyID, - privateKey: newPrivateKey.rawRepresentation - ) - } -} diff --git a/Sources/MistKit/Authentication/ServerToServerAuthManager.swift b/Sources/MistKit/Authentication/ServerToServerAuthManager.swift index 47b73b0e..9183c904 100644 --- a/Sources/MistKit/Authentication/ServerToServerAuthManager.swift +++ b/Sources/MistKit/Authentication/ServerToServerAuthManager.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -30,125 +30,97 @@ public import Crypto public import Foundation -/// Token manager for server-to-server authentication using ECDSA P-256 signing -/// Provides enterprise-level authentication for CloudKit Web Services -/// Available on macOS 11.0+, iOS 14.0+, tvOS 14.0+, watchOS 7.0+, and Linux -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +/// Token manager for server-to-server authentication using ECDSA P-256 signing. +/// Provides enterprise-level authentication for CloudKit Web Services. +/// Available on macOS 11.0+, iOS 14.0+, tvOS 14.0+, watchOS 7.0+, and Linux. public final class ServerToServerAuthManager: TokenManager, Sendable { internal let keyID: String - internal let privateKeyData: Data - internal let credentials: TokenCredentials + internal let privateKey: P256.Signing.PrivateKey + internal let bodyBufferLimit: Int // MARK: - TokenManager Protocol - /// Indicates whether valid credentials are currently available + /// Indicates whether valid credentials are currently available. public var hasCredentials: Bool { get async { !keyID.isEmpty } } - /// Creates a new server-to-server authentication manager + /// The raw representation of the private key (32 bytes for P-256). + internal var privateKeyData: Data { + privateKey.rawRepresentation + } + + /// Creates a new server-to-server authentication manager. /// - Parameters: - /// - keyID: The key identifier from Apple Developer Console - /// - privateKeyCallback: A closure that returns the ECDSA P-256 private key - /// - Throws: Error if the private key callback fails or the key is invalid + /// - keyID: The key identifier from Apple Developer Console. + /// - privateKeyCallback: A closure that returns the ECDSA P-256 private key. + /// - bodyBufferLimit: Maximum body size to buffer for signing. + /// - Throws: If the private key callback fails or the key is invalid. public init( keyID: String, - privateKeyCallback: @autoclosure @escaping @Sendable () throws -> P256.Signing.PrivateKey + privateKeyCallback: @autoclosure @escaping @Sendable () throws -> P256.Signing.PrivateKey, + bodyBufferLimit: Int = ServerToServerAuthenticator.defaultBodyBufferLimit ) throws { let privateKey = try privateKeyCallback() self.keyID = keyID - self.privateKeyData = privateKey.rawRepresentation - self.credentials = TokenCredentials.serverToServer( - keyID: keyID, - privateKey: privateKey.rawRepresentation - ) + self.privateKey = privateKey + self.bodyBufferLimit = bodyBufferLimit } - /// Convenience initializer with private key data - /// - Parameters: - /// - keyID: The key identifier from Apple Developer Console - /// - privateKeyData: The private key as raw data (32 bytes for P-256) - /// - Throws: Error if the private key data is invalid or cannot be parsed + /// Convenience initializer with private key data. public convenience init( keyID: String, - privateKeyData: Data + privateKeyData: Data, + bodyBufferLimit: Int = ServerToServerAuthenticator.defaultBodyBufferLimit ) throws { try self.init( keyID: keyID, - privateKeyCallback: try P256.Signing.PrivateKey(rawRepresentation: privateKeyData) + privateKeyCallback: try P256.Signing.PrivateKey(rawRepresentation: privateKeyData), + bodyBufferLimit: bodyBufferLimit ) } - /// Convenience initializer with PEM-formatted private key - /// - Parameters: - /// - keyID: The key identifier from Apple Developer Console - /// - pemString: The private key in PEM format - /// - Throws: TokenManagerError if the PEM string is invalid or cannot be parsed + /// Convenience initializer with PEM-formatted private key. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public convenience init( keyID: String, - pemString: String + pemString: String, + bodyBufferLimit: Int = ServerToServerAuthenticator.defaultBodyBufferLimit ) throws { do { try self.init( keyID: keyID, - privateKeyCallback: try P256.Signing.PrivateKey(pemRepresentation: pemString) + privateKeyCallback: try P256.Signing.PrivateKey(pemRepresentation: pemString), + bodyBufferLimit: bodyBufferLimit ) } catch { - // Provide more specific error handling for PEM parsing failures - if error.localizedDescription.contains("PEM") || error.localizedDescription.contains("format") + if error.localizedDescription.contains("PEM") + || error.localizedDescription.contains("format") { throw TokenManagerError.invalidCredentials(.invalidPEMFormat(error)) - } else { - throw TokenManagerError.invalidCredentials(.privateKeyParseFailed(error)) } + throw TokenManagerError.invalidCredentials(.privateKeyParseFailed(error)) } } - // MARK: - Private Key Access - - /// Creates a P256.Signing.PrivateKey from the stored private key data - /// This method is thread-safe as it creates a new instance each time - internal func createPrivateKey() throws(TokenManagerError) -> P256.Signing.PrivateKey { - do { - return try P256.Signing.PrivateKey(rawRepresentation: privateKeyData) - } catch { - throw TokenManagerError.invalidCredentials(.privateKeyInvalidOrCorrupted(error)) - } - } - - /// Validates the stored credentials for format and completeness - /// - Returns: true if credentials are valid, false otherwise - /// - Throws: TokenManagerError if credentials are invalid + /// Validates the stored credentials for format and completeness. public func validateCredentials() async throws(TokenManagerError) -> Bool { - guard !keyID.isEmpty else { - throw TokenManagerError.invalidCredentials(.keyIdEmpty) - } - - // Validate key ID format (typically alphanumeric with specific length) - guard keyID.count >= 8 else { - throw TokenManagerError.invalidCredentials(.keyIdTooShort) - } - - // Try to create a test signature to validate the private key - do { - let testData = Data("test".utf8) - let privateKey = try createPrivateKey() - _ = try privateKey.signature(for: testData) - } catch { - throw TokenManagerError.invalidCredentials(.privateKeyInvalidOrCorrupted(error)) - } - + _ = try ServerToServerAuthenticator( + keyID: keyID, + privateKey: privateKey, + bodyBufferLimit: bodyBufferLimit + ) return true } - /// Retrieves the current credentials for authentication - /// - Returns: The current token credentials, or nil if not available - /// - Throws: TokenManagerError if credentials are invalid - public func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - // Validate first - _ = try await validateCredentials() - return credentials + /// Returns the server-to-server authenticator, after validation. + public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try ServerToServerAuthenticator( + keyID: keyID, + privateKey: privateKey, + bodyBufferLimit: bodyBufferLimit + ) } } diff --git a/Sources/MistKit/Authentication/ServerToServerAuthenticator.swift b/Sources/MistKit/Authentication/ServerToServerAuthenticator.swift new file mode 100644 index 00000000..dd285d50 --- /dev/null +++ b/Sources/MistKit/Authentication/ServerToServerAuthenticator.swift @@ -0,0 +1,197 @@ +// +// ServerToServerAuthenticator.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Crypto +public import Foundation +public import HTTPTypes +public import OpenAPIRuntime + +/// Server-to-server authentication: signs each request with an ECDSA P-256 +/// private key and attaches the signature, key ID, and ISO-8601 date as +/// CloudKit-specific HTTP headers. +/// +/// The body is read once during signing. To keep downstream middleware +/// working with the same bytes regardless of `HTTPBody` iteration behavior, +/// `authenticate(request:body:)` reassigns `body` to a buffered copy. +public struct ServerToServerAuthenticator: Authenticator { + private struct WireFormat: Codable { + let keyID: String + let privateKey: String // base64-encoded raw representation + let bodyBufferLimit: Int? + } + + /// Stable storage key (`"server-to-server"`). + public static let storageKey: String = "server-to-server" + + /// Default upper bound (1 MiB) for buffering the request body when signing. + public static let defaultBodyBufferLimit: Int = 1_024 * 1_024 + + /// The CloudKit key identifier from Apple Developer Console. + public let keyID: String + + /// The ECDSA P-256 private key used to sign requests. + public let privateKey: P256.Signing.PrivateKey + + /// Maximum number of body bytes to buffer for signing. + /// Requests with larger bodies will fail to sign. + public let bodyBufferLimit: Int + + /// Identifier derived from the key ID so that distinct service-account + /// keys can be persisted side by side. + public var defaultStorageIdentifier: String { + "s2s-\(keyID)" + } + + /// The public key derived from the stored private key. + public var publicKey: P256.Signing.PublicKey { + privateKey.publicKey + } + + /// Creates an authenticator from a key ID and private key. + /// + /// - Parameters: + /// - keyID: The key identifier from Apple Developer Console. + /// - privateKey: The ECDSA P-256 private key. + /// - bodyBufferLimit: Maximum body size to buffer for signing. + /// Defaults to 1 MiB. + /// - Throws: `TokenManagerError.invalidCredentials` if `keyID` is empty + /// or shorter than 8 characters. The private key itself is not + /// re-validated here — a successfully-constructed `P256.Signing.PrivateKey` + /// is, by definition, capable of signing. The convenience initializers + /// that take raw data or a PEM string surface parse failures via that + /// conversion before reaching this initializer. + public init( + keyID: String, + privateKey: P256.Signing.PrivateKey, + bodyBufferLimit: Int = ServerToServerAuthenticator.defaultBodyBufferLimit + ) throws(TokenManagerError) { + guard !keyID.isEmpty else { + throw TokenManagerError.invalidCredentials(.keyIdEmpty) + } + guard keyID.count >= 8 else { + throw TokenManagerError.invalidCredentials(.keyIdTooShort) + } + self.keyID = keyID + self.privateKey = privateKey + self.bodyBufferLimit = bodyBufferLimit + } + + /// Convenience initializer with raw private key data (32 bytes for P-256). + public init( + keyID: String, + privateKeyData: Data, + bodyBufferLimit: Int = ServerToServerAuthenticator.defaultBodyBufferLimit + ) throws(TokenManagerError) { + let key: P256.Signing.PrivateKey + do { + key = try P256.Signing.PrivateKey(rawRepresentation: privateKeyData) + } catch { + throw TokenManagerError.invalidCredentials(.privateKeyInvalidOrCorrupted(error)) + } + try self.init(keyID: keyID, privateKey: key, bodyBufferLimit: bodyBufferLimit) + } + + /// Convenience initializer with a PEM-encoded private key string. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + public init( + keyID: String, + pemString: String, + bodyBufferLimit: Int = ServerToServerAuthenticator.defaultBodyBufferLimit + ) throws(TokenManagerError) { + let key: P256.Signing.PrivateKey + do { + key = try P256.Signing.PrivateKey(pemRepresentation: pemString) + } catch { + if error.localizedDescription.contains("PEM") + || error.localizedDescription.contains("format") + { + throw TokenManagerError.invalidCredentials(.invalidPEMFormat(error)) + } + throw TokenManagerError.invalidCredentials(.privateKeyParseFailed(error)) + } + try self.init(keyID: keyID, privateKey: key, bodyBufferLimit: bodyBufferLimit) + } + + /// Reconstructs a `ServerToServerAuthenticator` from data previously + /// produced by `encoded()`. Re-runs key parse + key-ID validation, so a + /// corrupted payload throws `TokenManagerError.invalidCredentials`. + public init(decoding data: Data) throws { + let wire = try JSONDecoder().decode(WireFormat.self, from: data) + guard let keyData = Data(base64Encoded: wire.privateKey) else { + throw TokenManagerError.invalidCredentials(.encodedPayloadInvalidBase64) + } + try self.init( + keyID: wire.keyID, + privateKeyData: keyData, + bodyBufferLimit: wire.bodyBufferLimit ?? Self.defaultBodyBufferLimit + ) + } + + /// Buffers the request body, signs the body + path with the stored private + /// key, and writes the CloudKit signature headers + /// (`X-Apple-CloudKit-Request-KeyID`, `…ISO8601Date`, `…SignatureV1`). + /// The body is reassigned to the buffered copy so downstream middleware + /// sees the same bytes regardless of `HTTPBody` iteration behavior. + /// + /// - Throws: `OpenAPIRuntime` errors when buffering fails or the body + /// exceeds `bodyBufferLimit`; crypto errors from `P256.Signing` if + /// signing fails. + public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? + ) async throws { + // Buffer the body so we can both sign it and forward the same bytes. + // If buffering fails (oversize body, transport error) we propagate the + // error rather than signing over an empty body and mismatching what the + // downstream transport actually sends. + let bodyData = try await Data(buffering: &body, upTo: bodyBufferLimit) + + let signature = try RequestSignature( + keyID: keyID, + privateKey: privateKey, + requestBody: bodyData, + webServiceSubpath: request.path + ) + + request.headerFields.append(contentsOf: signature.headers) + } + + /// JSON-encodes the key ID, base64-encoded private key, and + /// `bodyBufferLimit` for persistence by `TokenStorage`. The output + /// contains raw P-256 key material — see the protocol-level warning on + /// `Authenticator.encoded()`. + public func encoded() throws -> Data { + let wire = WireFormat( + keyID: keyID, + privateKey: privateKey.rawRepresentation.base64EncodedString(), + bodyBufferLimit: bodyBufferLimit + ) + return try JSONEncoder().encode(wire) + } +} diff --git a/Sources/MistKit/Authentication/ServerToServerCredentials.swift b/Sources/MistKit/Authentication/ServerToServerCredentials.swift new file mode 100644 index 00000000..21cc5465 --- /dev/null +++ b/Sources/MistKit/Authentication/ServerToServerCredentials.swift @@ -0,0 +1,45 @@ +// +// ServerToServerCredentials.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Server-to-server signing credentials for the public CloudKit database. +/// +/// CloudKit accepts server-to-server signing only against the **public** +/// database. Private and shared databases require web-auth credentials. +public struct ServerToServerCredentials: Sendable { + /// Hex-encoded CloudKit server-to-server key ID issued in CloudKit Dashboard. + public let keyID: String + /// EC P-256 private key material that signs each CloudKit request. + public let privateKey: PrivateKeyMaterial + + /// Construct credentials from a CloudKit key ID and matching private key. + public init(keyID: String, privateKey: PrivateKeyMaterial) { + self.keyID = keyID + self.privateKey = privateKey + } +} diff --git a/Sources/MistKit/Authentication/TokenCredentials.swift b/Sources/MistKit/Authentication/TokenCredentials.swift deleted file mode 100644 index 160e2c3f..00000000 --- a/Sources/MistKit/Authentication/TokenCredentials.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// TokenCredentials.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Encapsulates authentication credentials for CloudKit Web Services -public struct TokenCredentials: Sendable, Equatable { - /// The authentication method and associated credentials - public let method: AuthenticationMethod - - /// Optional metadata for tracking token creation or expiry - public let metadata: [String: String] - - /// Returns true if these credentials support user-specific operations - public var supportsUserOperations: Bool { - switch method { - case .apiToken, .serverToServer: - return false - case .webAuthToken: - return true - } - } - - /// Returns the authentication method type as a string - public var methodType: String { - method.methodType - } - - /// Creates new token credentials with the specified authentication method - /// - Parameters: - /// - method: The authentication method to use - /// - metadata: Optional metadata for tracking purposes - public init(method: AuthenticationMethod, metadata: [String: String] = [:]) { - self.method = method - self.metadata = metadata - } - - /// Convenience initializer for API token authentication - /// - Parameter apiToken: The API token string - /// - Returns: TokenCredentials configured for API token authentication - public static func apiToken(_ apiToken: String) -> TokenCredentials { - TokenCredentials(method: .apiToken(apiToken)) - } - - /// Convenience initializer for web authentication - /// - Parameters: - /// - apiToken: The API token string - /// - webToken: The web authentication token string - /// - Returns: TokenCredentials configured for web authentication - public static func webAuthToken(apiToken: String, webToken: String) -> TokenCredentials { - TokenCredentials(method: .webAuthToken(apiToken: apiToken, webToken: webToken)) - } - - /// Convenience initializer for server-to-server authentication - /// - Parameters: - /// - keyID: The key identifier - /// - privateKey: The ECDSA P-256 private key data - /// - Returns: TokenCredentials configured for server-to-server authentication - public static func serverToServer(keyID: String, privateKey: Data) -> TokenCredentials { - TokenCredentials(method: .serverToServer(keyID: keyID, privateKey: privateKey)) - } -} diff --git a/Sources/MistKit/Authentication/TokenManager.swift b/Sources/MistKit/Authentication/TokenManager.swift index 7cf4ae59..6c188ecb 100644 --- a/Sources/MistKit/Authentication/TokenManager.swift +++ b/Sources/MistKit/Authentication/TokenManager.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -29,49 +29,21 @@ import Foundation -/// Protocol for managing authentication tokens and credentials for CloudKit Web Services +/// Protocol for managing authentication tokens and credentials for CloudKit Web Services. +/// +/// A `TokenManager` is the lifecycle owner of credentials (loading, validating, +/// rotating, persisting). It vends an `Authenticator` to whomever needs to apply +/// those credentials to an outgoing request. public protocol TokenManager: Sendable { - /// Checks if credentials are currently available + /// Checks if credentials are currently available. var hasCredentials: Bool { get async } - /// Validates the current authentication credentials - /// - Returns: True if credentials are valid and usable - /// - Throws: TokenManagerError if validation fails + /// Validates the current authentication credentials. + /// - Returns: True if credentials are valid and usable. + /// - Throws: `TokenManagerError` if validation fails. func validateCredentials() async throws(TokenManagerError) -> Bool - /// Retrieves the current token credentials - /// - Returns: Current TokenCredentials or nil if none available - /// - Throws: TokenManagerError if retrieval fails - func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? -} - -extension TokenManager { - /// Validates API token format using regex - /// - Parameter apiToken: The API token to validate - /// - Throws: TokenManagerError if validation fails - internal static func validateAPITokenFormat(_ apiToken: String) throws(TokenManagerError) { - guard !apiToken.isEmpty else { - throw TokenManagerError.invalidCredentials(.apiTokenEmpty) - } - - let regex = NSRegularExpression.apiTokenRegex - let matches = regex.matches(in: apiToken) - - guard !matches.isEmpty else { - throw TokenManagerError.invalidCredentials(.apiTokenInvalidFormat) - } - } - - /// Validates web auth token format - /// - Parameter webToken: The web auth token to validate - /// - Throws: TokenManagerError if validation fails - internal static func validateWebAuthTokenFormat(_ webToken: String) throws(TokenManagerError) { - guard !webToken.isEmpty else { - throw TokenManagerError.invalidCredentials(.webAuthTokenEmpty) - } - - guard webToken.count >= 10 else { - throw TokenManagerError.invalidCredentials(.webAuthTokenTooShort) - } - } + /// Returns the authenticator that should be used for the next request, + /// or `nil` if no credentials are available. + func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? } diff --git a/Sources/MistKit/Authentication/TokenManagerError.swift b/Sources/MistKit/Authentication/TokenManagerError.swift index ce30d9bd..a6f915b0 100644 --- a/Sources/MistKit/Authentication/TokenManagerError.swift +++ b/Sources/MistKit/Authentication/TokenManagerError.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -35,13 +35,13 @@ public enum TokenManagerError: Error, LocalizedError, Sendable { case invalidCredentials(InvalidCredentialReason) /// Authentication failed with external service - case authenticationFailed(underlying: (any Error)?) + case authenticationFailed(AuthenticationFailedReason) /// Token has expired and cannot be used case tokenExpired /// Network or communication error during authentication - case networkError(underlying: any Error) + case networkError(NetworkErrorReason) /// Internal error in token management case internalError(InternalErrorReason) @@ -51,12 +51,12 @@ public enum TokenManagerError: Error, LocalizedError, Sendable { switch self { case .invalidCredentials(let reason): return "Invalid credentials: \(reason.description)" - case .authenticationFailed(let error): - return "Authentication failed: \(error?.localizedDescription ?? "Unknown error")" + case .authenticationFailed(let reason): + return "Authentication failed: \(reason.description)" case .tokenExpired: return "Authentication token has expired" - case .networkError(let error): - return "Network error during authentication: \(error.localizedDescription)" + case .networkError(let reason): + return "Network error during authentication: \(reason.description)" case .internalError(let reason): return "Internal token manager error: \(reason.description)" } diff --git a/Sources/MistKit/Authentication/TokenStorage.swift b/Sources/MistKit/Authentication/TokenStorage.swift index 74122e00..870f6e96 100644 --- a/Sources/MistKit/Authentication/TokenStorage.swift +++ b/Sources/MistKit/Authentication/TokenStorage.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,27 +27,29 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// Protocol for persisting and retrieving authentication tokens/keys +/// Protocol for persisting and retrieving authenticators. public protocol TokenStorage: Sendable { - /// Stores token credentials with an optional identifier + /// Stores an authenticator with an optional identifier. /// - Parameters: - /// - credentials: The credentials to store - /// - identifier: Optional identifier for multiple credential storage - /// - Throws: TokenStorageError if storage fails - func store(_ credentials: TokenCredentials, identifier: String?) async throws(TokenStorageError) + /// - authenticator: The authenticator to persist. + /// - identifier: Optional identifier for storing multiple authenticators. + /// - Throws: `TokenStorageError` if storage fails. + func store( + _ authenticator: any Authenticator, + identifier: String? + ) async throws(TokenStorageError) - /// Retrieves stored token credentials - /// - Parameter identifier: Optional identifier for specific credentials - /// - Returns: Stored credentials or nil if not found - /// - Throws: TokenStorageError if retrieval fails - func retrieve(identifier: String?) async throws(TokenStorageError) -> TokenCredentials? + /// Retrieves a stored authenticator. + /// - Parameter identifier: Optional identifier for specific credentials. + /// - Returns: The stored authenticator, or `nil` if not found. + /// - Throws: `TokenStorageError` if retrieval fails. + func retrieve(identifier: String?) async throws(TokenStorageError) -> (any Authenticator)? - /// Removes stored credentials - /// - Parameter identifier: Optional identifier for specific credentials - /// - Throws: TokenStorageError if removal fails + /// Removes a stored authenticator. + /// - Parameter identifier: Optional identifier for specific credentials. + /// - Throws: `TokenStorageError` if removal fails. func remove(identifier: String?) async throws(TokenStorageError) - /// Lists all stored credential identifiers - /// - Returns: Array of stored identifiers + /// Lists all stored authenticator identifiers. func listIdentifiers() async throws(TokenStorageError) -> [String] } diff --git a/Sources/MistKit/Authentication/TokenStorageError.swift b/Sources/MistKit/Authentication/TokenStorageError.swift index 21326abd..f9a55e39 100644 --- a/Sources/MistKit/Authentication/TokenStorageError.swift +++ b/Sources/MistKit/Authentication/TokenStorageError.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Authentication/WebAuthTokenAuthenticator.swift b/Sources/MistKit/Authentication/WebAuthTokenAuthenticator.swift new file mode 100644 index 00000000..6f4135b7 --- /dev/null +++ b/Sources/MistKit/Authentication/WebAuthTokenAuthenticator.swift @@ -0,0 +1,119 @@ +// +// WebAuthTokenAuthenticator.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import HTTPTypes +public import OpenAPIRuntime + +/// CloudKit web-authentication: appends `ckAPIToken=...` and a +/// character-map-encoded `ckWebAuthToken=...` as query items. +/// +/// Required for user-specific operations on the private database. +public struct WebAuthTokenAuthenticator: Authenticator { + private struct WireFormat: Codable { + let apiToken: String + let webAuthToken: String + } + + /// Stable storage key (`"web-auth-token"`). + public static let storageKey: String = "web-auth-token" + + private static let encoder = CharacterMapEncoder() + + /// The 64-character hex CloudKit API token. + public let apiToken: String + + /// The web authentication token issued by CloudKit JS. + public let webAuthToken: String + + /// Identifier derived from the first 8 characters of `apiToken` so that + /// distinct authenticated sessions can be persisted side by side. + public var defaultStorageIdentifier: String { + "web-\(apiToken.prefix(8))" + } + + /// The web auth token after applying CloudKit's character-map encoding. + public var encodedWebAuthToken: String { + Self.encoder.encode(webAuthToken) + } + + /// Creates an authenticator from API and web-auth tokens. + /// - Parameters: + /// - apiToken: The CloudKit API token. + /// - webAuthToken: The web authentication token. + /// - Throws: `TokenManagerError.invalidCredentials` if either token is + /// empty, the API token has the wrong format, or the web auth token is + /// too short. + public init( + apiToken: String, + webAuthToken: String + ) throws(TokenManagerError) { + guard !apiToken.isEmpty else { + throw TokenManagerError.invalidCredentials(.apiTokenEmpty) + } + let regex = NSRegularExpression.apiTokenRegex + guard !regex.matches(in: apiToken).isEmpty else { + throw TokenManagerError.invalidCredentials(.apiTokenInvalidFormat) + } + guard !webAuthToken.isEmpty else { + throw TokenManagerError.invalidCredentials(.webAuthTokenEmpty) + } + guard webAuthToken.count >= 10 else { + throw TokenManagerError.invalidCredentials(.webAuthTokenTooShort) + } + self.apiToken = apiToken + self.webAuthToken = webAuthToken + } + + /// Reconstructs a `WebAuthTokenAuthenticator` from data previously + /// produced by `encoded()`. Re-runs format validation, so a corrupted + /// or stale payload throws `TokenManagerError.invalidCredentials`. + public init(decoding data: Data) throws { + let wire = try JSONDecoder().decode(WireFormat.self, from: data) + try self.init(apiToken: wire.apiToken, webAuthToken: wire.webAuthToken) + } + + /// Appends `ckAPIToken` and a character-map-encoded `ckWebAuthToken` as + /// query items on the outgoing request. + public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? + ) async throws { + let encoded = Self.encoder.encode(webAuthToken) + request.appendQueryItems([ + URLQueryItem(name: "ckAPIToken", value: apiToken), + URLQueryItem(name: "ckWebAuthToken", value: encoded), + ]) + } + + /// JSON-encodes both tokens for persistence by `TokenStorage`. + public func encoded() throws -> Data { + try JSONEncoder().encode(WireFormat(apiToken: apiToken, webAuthToken: webAuthToken)) + } +} diff --git a/Sources/MistKit/Authentication/WebAuthTokenManager+Methods.swift b/Sources/MistKit/Authentication/WebAuthTokenManager+Methods.swift deleted file mode 100644 index 11f47232..00000000 --- a/Sources/MistKit/Authentication/WebAuthTokenManager+Methods.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// WebAuthTokenManager+Methods.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -// MARK: - Additional Web Auth Methods - -extension WebAuthTokenManager { - /// The API token value - public var apiTokenValue: String { - apiToken - } - - /// The web authentication token value - public var webAuthTokenValue: String { - webAuthToken - } - - /// Returns the encoded web auth token (using CharacterMapEncoder) - public var encodedWebAuthToken: String { - tokenEncoder.encode(webAuthToken) - } - - /// Returns true if both tokens appear to be in valid format - public var areTokensValidFormat: Bool { - do { - try Self.validateAPITokenFormat(apiToken) - try Self.validateWebAuthTokenFormat(webAuthToken) - return true - } catch { - return false - } - } - - /// Creates credentials with additional metadata - /// - Parameter metadata: Additional metadata to include - /// - Returns: TokenCredentials with metadata - public func credentialsWithMetadata(_ metadata: [String: String]) -> TokenCredentials { - TokenCredentials( - method: .webAuthToken(apiToken: apiToken, webToken: webAuthToken), - metadata: metadata - ) - } - - /// Creates new credentials with updated web auth token (for token refresh scenarios) - /// - Parameter newWebAuthToken: The new web authentication token - /// - Returns: New TokenCredentials with updated web token - /// - Note: This creates new credentials but doesn't update the manager's internal token - public func credentialsWithUpdatedWebAuthToken(_ newWebAuthToken: String) -> TokenCredentials { - TokenCredentials.webAuthToken( - apiToken: apiToken, - webToken: newWebAuthToken - ) - } -} diff --git a/Sources/MistKit/Authentication/WebAuthTokenManager.swift b/Sources/MistKit/Authentication/WebAuthTokenManager.swift index 788b4329..0976a7ec 100644 --- a/Sources/MistKit/Authentication/WebAuthTokenManager.swift +++ b/Sources/MistKit/Authentication/WebAuthTokenManager.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -29,97 +29,41 @@ import Foundation -/// Token manager for web authentication with API token + web auth token -/// Provides user-specific access to CloudKit Web Services +/// Token manager for web authentication with API token + web auth token. +/// Provides user-specific access to CloudKit Web Services. public final class WebAuthTokenManager: TokenManager, Sendable { internal let apiToken: String internal let webAuthToken: String - internal let tokenEncoder = CharacterMapEncoder() - internal let credentials: TokenCredentials // MARK: - TokenManager Protocol - /// Indicates whether valid credentials are currently available + /// Indicates whether valid credentials are currently available. public var hasCredentials: Bool { get async { - // Check if tokens are non-empty and have valid format - guard !apiToken.isEmpty && !webAuthToken.isEmpty else { - return - false - } - - // Check API token format (64-character hex string) - let regex = NSRegularExpression.apiTokenRegex - let matches = regex.matches(in: apiToken) - guard !matches.isEmpty else { - return - false - } - - // Check web auth token length (at least 10 characters) - guard webAuthToken.count >= 10 else { - return - false - } - - return true + (try? WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webAuthToken)) != nil } } - /// Creates a new web authentication token manager + /// Creates a new web authentication token manager. /// - Parameters: - /// - apiToken: The CloudKit API token from Apple Developer Console - /// - webAuthToken: The web authentication token from CloudKit JS authentication + /// - apiToken: The CloudKit API token from Apple Developer Console. + /// - webAuthToken: The web authentication token from CloudKit JS authentication. public init( apiToken: String, webAuthToken: String ) { self.apiToken = apiToken self.webAuthToken = webAuthToken - self.credentials = TokenCredentials.webAuthToken( - apiToken: apiToken, - webToken: webAuthToken - ) } - /// Validates the stored credentials for format and completeness - /// - Returns: true if credentials are valid, false otherwise - /// - Throws: TokenManagerError if credentials are invalid + /// Validates the stored credentials for format and completeness. public func validateCredentials() async throws(TokenManagerError) -> Bool { - // Validate API token format - guard !apiToken.isEmpty else { - throw TokenManagerError.invalidCredentials(.apiTokenEmpty) - } - - let regex = NSRegularExpression.apiTokenRegex - let matches = regex.matches(in: apiToken) - - guard !matches.isEmpty else { - throw TokenManagerError.invalidCredentials(.apiTokenInvalidFormat) - } - - // Validate web auth token - guard !webAuthToken.isEmpty else { - throw TokenManagerError.invalidCredentials(.webAuthTokenEmpty) - } - - guard webAuthToken.count >= 10 else { - throw TokenManagerError.invalidCredentials(.webAuthTokenTooShort) - } - + _ = try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webAuthToken) return true } - /// Retrieves the current credentials for authentication - /// - Returns: The current token credentials, or nil if not available - /// - Throws: TokenManagerError if credentials are invalid - public func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - // Validate first - _ = try await validateCredentials() - return credentials - } - - deinit { - // Clean up any resources + /// Returns the web-auth authenticator, after validation. + public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webAuthToken) } } diff --git a/Sources/MistKit/AuthenticationMiddleware.swift b/Sources/MistKit/AuthenticationMiddleware.swift deleted file mode 100644 index 27236350..00000000 --- a/Sources/MistKit/AuthenticationMiddleware.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// AuthenticationMiddleware.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime - -/// Authentication middleware for CloudKit requests using TokenManager -internal struct AuthenticationMiddleware: ClientMiddleware { - internal let tokenManager: any TokenManager - private let tokenEncoder = CharacterMapEncoder() - - /// Creates authentication middleware with a TokenManager - /// - Parameter tokenManager: The token manager to use for authentication - internal init(tokenManager: any TokenManager) { - self.tokenManager = tokenManager - } - - internal func intercept( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) - ) async throws -> (HTTPResponse, HTTPBody?) { - // Get credentials from token manager - guard let credentials = try await tokenManager.getCurrentCredentials() else { - throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) - } - - var modifiedRequest = request - var urlComponents = parseRequestPath(request.path ?? "") - - // Apply authentication based on method type - switch credentials.method { - case .apiToken(let apiToken): - addAPITokenAuthentication(apiToken: apiToken, to: &urlComponents) - - case .webAuthToken(let apiToken, let webToken): - addWebAuthTokenAuthentication(apiToken: apiToken, webToken: webToken, to: &urlComponents) - - case .serverToServer: - modifiedRequest = try await addServerToServerAuthentication(to: modifiedRequest, body: body) - } - - // Build the new path with query parameters (for API and Web auth) - updateRequestPath(&modifiedRequest, with: urlComponents) - - return try await next(modifiedRequest, body, baseURL) - } - - // MARK: - Private Helper Methods - - private func parseRequestPath(_ requestPath: String) -> URLComponents { - let pathComponents = requestPath.split(separator: "?", maxSplits: 1) - let cleanPath = String(pathComponents.first ?? "") - - var urlComponents = URLComponents() - urlComponents.path = cleanPath - - // Parse existing query items if any - if pathComponents.count > 1 { - let existingQuery = String(pathComponents[1]) - if let existingComponents = URLComponents(string: "?" + existingQuery) { - urlComponents.queryItems = existingComponents.queryItems ?? [] - } - } - - return urlComponents - } - - private func addAPITokenAuthentication(apiToken: String, to urlComponents: inout URLComponents) { - var queryItems = urlComponents.queryItems ?? [] - queryItems.append(URLQueryItem(name: "ckAPIToken", value: apiToken)) - urlComponents.queryItems = queryItems - } - - private func addWebAuthTokenAuthentication( - apiToken: String, - webToken: String, - to urlComponents: inout URLComponents - ) { - var queryItems = urlComponents.queryItems ?? [] - queryItems.append(URLQueryItem(name: "ckAPIToken", value: apiToken)) - let encodedWebAuthToken = tokenEncoder.encode(webToken) - queryItems.append(URLQueryItem(name: "ckWebAuthToken", value: encodedWebAuthToken)) - urlComponents.queryItems = queryItems - } - - private func addServerToServerAuthentication( - to request: HTTPRequest, - body: HTTPBody? - ) async throws -> HTTPRequest { - // Server-to-server authentication uses ECDSA P-256 signature in headers - // Available on macOS 11.0+, iOS 14.0+, tvOS 14.0+, watchOS 7.0+, and Linux - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - throw TokenManagerError.internalError(.serverToServerRequiresPlatformSupport) - } - - guard let serverAuthManager = tokenManager as? ServerToServerAuthManager else { - throw TokenManagerError.internalError(.serverToServerRequiresSpecificManager) - } - - // Extract body data for signing - let requestBodyData = try await extractRequestBodyData(from: body) - let webServiceSubpath = request.path ?? "" - - let signature = try serverAuthManager.signRequest( - requestBody: requestBodyData, - webServiceURL: webServiceSubpath - ) - - var modifiedRequest = request - modifiedRequest.headerFields[.cloudKitRequestKeyID] = signature.keyID - modifiedRequest.headerFields[.cloudKitRequestISO8601Date] = signature.date - modifiedRequest.headerFields[.cloudKitRequestSignatureV1] = signature.signature - - return modifiedRequest - } - - private func extractRequestBodyData(from body: HTTPBody?) async throws -> Data? { - guard let body = body else { - return nil - } - - do { - return try await Data(collecting: body, upTo: 1_024 * 1_024) - } catch { - return nil - } - } - - private func updateRequestPath(_ request: inout HTTPRequest, with urlComponents: URLComponents) { - let cleanPath = urlComponents.path - if let query = urlComponents.query { - request.path = cleanPath + "?" + query - } else { - request.path = cleanPath - } - } -} diff --git a/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift b/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift new file mode 100644 index 00000000..8b91f8cd --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift @@ -0,0 +1,69 @@ +// +// CloudKitError+OpenAPI.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Logging +internal import MistKitOpenAPI + +extension CloudKitError { + /// Generic failable initializer for any `CloudKitResponseType`. + /// Returns `nil` when the response is `.ok`. + internal init?(_ response: T) { + guard let error = response.toCloudKitError() else { + return nil + } + self = error + } + + /// Build a `CloudKitError` from any CloudKit failure response. + /// The body schema is identical across status codes — only the code + /// disambiguates which CloudKit failure occurred, so the caller supplies it. + internal init(_ response: Components.Responses.Failure, statusCode: Int) { + switch response.body { + case .json(let errorResponse): + self = .httpErrorWithDetails( + statusCode: statusCode, + serverErrorCode: errorResponse.serverErrorCode?.rawValue, + reason: errorResponse.reason + ) + } + } + + /// Build an `.httpError` for an undocumented response and log the occurrence. + /// The full response value is logged at `.debug` because it may echo server-side + /// request data (e.g. emails passed to `lookupUsersByEmail`); the `.warning` line + /// stays sanitized so it can ship to ops/log aggregators without leaking PII. + internal static func undocumented(statusCode: Int, response: some Any) -> CloudKitError { + let logger = Logger(subsystem: .api) + logger.debug("Unhandled response (HTTP \(statusCode)): \(response)") + logger.warning( + "Unhandled \(type(of: response)) (HTTP \(statusCode)) - treating as generic HTTP error" + ) + return .httpError(statusCode: statusCode) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift new file mode 100644 index 00000000..4d5d416e --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -0,0 +1,151 @@ +// +// CloudKitError.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Represents errors that can occur when interacting with CloudKit Web Services +public enum CloudKitError: LocalizedError, Sendable { + case httpError(statusCode: Int) + case httpErrorWithDetails(statusCode: Int, serverErrorCode: String?, reason: String?) + case httpErrorWithRawResponse(statusCode: Int, rawResponse: String) + case invalidResponse + case underlyingError(any Error) + case decodingError(DecodingError) + case networkError(URLError) + case unsupportedOperationType(String) + case paginationLimitExceeded(maxPages: Int, records: [RecordInfo]) + case missingCredentials( + database: Database, + availability: CredentialAvailability = .notConfigured, + reason: String + ) + case invalidPrivateKey(path: String?, underlying: any Error) + + /// HTTP status code if this error originated from an HTTP response, otherwise nil. + public var httpStatusCode: Int? { + switch self { + case .httpError(let statusCode), + .httpErrorWithDetails(let statusCode, _, _), + .httpErrorWithRawResponse(let statusCode, _): + return statusCode + case .invalidResponse, .underlyingError, .decodingError, .networkError, + .unsupportedOperationType, .paginationLimitExceeded, .missingCredentials, + .invalidPrivateKey: + return nil + } + } + + /// A localized message describing what error occurred + public var errorDescription: String? { + switch self { + case .httpError(let statusCode): + return "CloudKit API error: HTTP \(statusCode)" + case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason): + var message = "CloudKit API error: HTTP \(statusCode)" + if let serverErrorCode = serverErrorCode { + message += "\nServer Error Code: \(serverErrorCode)" + } + if let reason = reason { + message += "\nReason: \(reason)" + } + return message + case .httpErrorWithRawResponse(let statusCode, let rawResponse): + return "CloudKit API error: HTTP \(statusCode)\nRaw Response: \(rawResponse)" + case .invalidResponse: + return "Invalid response from CloudKit" + case .underlyingError(let error): + return "CloudKit operation failed with underlying error: \(String(reflecting: error))" + case .decodingError(let error): + var message = "Failed to decode CloudKit response" + switch error { + case .keyNotFound(let key, let context): + message += "\nMissing key: \(key.stringValue)" + message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" + if let underlyingError = context.underlyingError { + message += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + case .typeMismatch(let type, let context): + message += "\nType mismatch: expected \(type)" + message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" + if let underlyingError = context.underlyingError { + message += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + case .valueNotFound(let type, let context): + message += "\nValue not found: expected \(type)" + message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" + if let underlyingError = context.underlyingError { + message += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + case .dataCorrupted(let context): + message += "\nData corrupted" + message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" + if let underlyingError = context.underlyingError { + message += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + @unknown default: + message += "\nUnknown decoding error: \(error.localizedDescription)" + } + return message + case .networkError(let error): + var message = "Network error occurred" + message += "\nError code: \(error.code.rawValue)" + if let url = error.failureURLString { + message += "\nFailed URL: \(url)" + } + message += "\nDescription: \(error.localizedDescription)" + return message + case .unsupportedOperationType(let type): + return "Unsupported record operation type: \(type)" + case .paginationLimitExceeded(let maxPages, let records): + return + "CloudKit query exceeded pagination limit of \(maxPages) pages " + + "(collected \(records.count) records)" + case .missingCredentials(let database, let availability, let reason): + let availabilityLabel: String + switch availability { + case .notConfigured: + availabilityLabel = "not configured" + case .preferenceRequired: + availabilityLabel = "required by preference but not configured" + } + return + "Missing credentials for database '\(database.pathSegment)' " + + "(\(availabilityLabel)): \(reason)" + case .invalidPrivateKey(let path, let underlying): + let location = path.map { "from '\($0)'" } ?? "from inline material" + return + "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift new file mode 100644 index 00000000..84fe8e68 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift @@ -0,0 +1,158 @@ +// +// CloudKitResponseProcessor+Changes.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI +import OpenAPIRuntime + +extension CloudKitResponseProcessor { + /// Process fetchRecordChanges response + /// - Parameter response: The response to process + /// - Returns: The extracted changes response data + /// - Throws: CloudKitError for various error conditions + internal func processFetchRecordChangesResponse(_ response: Operations.fetchRecordChanges.Output) + async throws(CloudKitError) -> Components.Schemas.ChangesResponse + { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let changesData): + return changesData + } + default: + // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process discoverUserIdentities response + internal func processDiscoverUserIdentitiesResponse( + _ response: Operations.discoverUserIdentities.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let discoverData): + return discoverData + } + default: + // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process lookupUsersByEmail response + internal func processLookupUsersByEmailResponse( + _ response: Operations.lookupUsersByEmail.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let discoverData): + return discoverData + } + default: + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process lookupUsersByRecordName response + internal func processLookupUsersByRecordNameResponse( + _ response: Operations.lookupUsersByRecordName.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let discoverData): + return discoverData + } + default: + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process uploadAssets response + /// - Parameter response: The response to process + /// - Returns: The extracted asset upload response data + /// - Throws: CloudKitError for various error conditions + internal func processUploadAssetsResponse(_ response: Operations.uploadAssets.Output) + async throws(CloudKitError) -> Components.Schemas.AssetUploadResponse + { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let uploadData): + return uploadData + } + default: + // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process fetchZoneChanges response + internal func processFetchZoneChangesResponse(_ response: Operations.fetchZoneChanges.Output) + async throws(CloudKitError) -> Components.Schemas.ZoneChangesResponse + { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let changesData): + return changesData + } + default: + // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+ModifyZones.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+ModifyZones.swift new file mode 100644 index 00000000..93467348 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+ModifyZones.swift @@ -0,0 +1,55 @@ +// +// CloudKitResponseProcessor+ModifyZones.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension CloudKitResponseProcessor { + /// Process modifyZones response + /// - Parameter response: The response to process + /// - Returns: The extracted zones modify data + /// - Throws: CloudKitError for various error conditions + internal func processModifyZonesResponse(_ response: Operations.modifyZones.Output) + async throws(CloudKitError) -> Components.Schemas.ZonesModifyResponse + { + if let error = CloudKitError(response) { + throw error + } + + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let zonesData): + return zonesData + } + default: + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } +} diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor.swift similarity index 92% rename from Sources/MistKit/Service/CloudKitResponseProcessor.swift rename to Sources/MistKit/CloudKitService/CloudKitResponseProcessor.swift index e52c38f7..df5472bb 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor.swift +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,15 +28,17 @@ // internal import Foundation +internal import Logging +internal import MistKitOpenAPI import OpenAPIRuntime /// Processes CloudKit API responses and handles errors internal struct CloudKitResponseProcessor { - /// Process getCurrentUser response + /// Process getCaller response /// - Parameter response: The response to process /// - Returns: The extracted user data /// - Throws: CloudKitError for various error conditions - internal func processGetCurrentUserResponse(_ response: Operations.getCurrentUser.Output) + internal func processGetCallerResponse(_ response: Operations.getCaller.Output) async throws(CloudKitError) -> Components.Schemas.UserResponse { // Check for errors first @@ -50,13 +52,14 @@ internal struct CloudKitResponseProcessor { return try extractUserData(from: okResponse) default: // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") throw CloudKitError.invalidResponse } } /// Extract user data from OK response private func extractUserData( - from response: Operations.getCurrentUser.Output.Ok + from response: Operations.getCaller.Output.Ok ) throws(CloudKitError) -> Components.Schemas.UserResponse { switch response.body { case .json(let userData): @@ -126,11 +129,8 @@ internal struct CloudKitResponseProcessor { { // Check for errors first if let error = CloudKitError(response) { - // Log error with full details when redaction is disabled - MistKitLogger.logError( - "CloudKit queryRecords failed with response: \(response)", - logger: MistKitLogger.api, - shouldRedact: false + Logger(subsystem: .api).error( + "CloudKit queryRecords failed with response: \(response)" ) throw error } @@ -170,6 +170,7 @@ internal struct CloudKitResponseProcessor { } default: // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") throw CloudKitError.invalidResponse } } diff --git a/Sources/MistKit/Service/CloudKitService+AssetOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift similarity index 86% rename from Sources/MistKit/Service/CloudKitService+AssetOperations.swift rename to Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift index de3426c7..fbb9ce05 100644 --- a/Sources/MistKit/Service/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -29,6 +29,7 @@ public import Foundation import HTTPTypes +internal import MistKitOpenAPI import OpenAPIRuntime #if canImport(FoundationNetworking) @@ -39,7 +40,7 @@ import OpenAPIRuntime import OpenAPIURLSession #endif -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension CloudKitService { /// Upload binary asset data to CloudKit /// @@ -54,6 +55,7 @@ extension CloudKitService { /// - fieldName: The name of the asset field /// - recordName: Optional unique record name /// - uploader: Optional custom upload handler + /// - database: The CloudKit database scope to upload to (`.public`, `.private`, `.shared`) /// - Returns: AssetUploadReceipt containing the upload result /// - Throws: CloudKitError if the upload fails /// @@ -74,7 +76,8 @@ extension CloudKitService { recordType: String, fieldName: String, recordName: String? = nil, - using uploader: AssetUploader? = nil + using uploader: AssetUploader? = nil, + database: Database ) async throws(CloudKitError) -> AssetUploadReceipt { let maxSize: Int = 15 * 1_024 * 1_024 guard data.count <= maxSize else { @@ -96,7 +99,8 @@ extension CloudKitService { let urlToken = try await requestAssetUploadURL( recordType: recordType, fieldName: fieldName, - recordName: recordName + recordName: recordName, + database: database ) guard let uploadURL = urlToken.url else { @@ -129,13 +133,15 @@ extension CloudKitService { /// - fieldName: The name of the asset field /// - recordName: Optional unique record name /// - zoneID: Optional zone ID (defaults to default zone) + /// - database: The CloudKit database scope (`.public`, `.private`, `.shared`) /// - Returns: AssetUploadToken containing the upload URL /// - Throws: CloudKitError if the request fails public func requestAssetUploadURL( recordType: String, fieldName: String, recordName: String? = nil, - zoneID: ZoneID? = nil + zoneID: ZoneID? = nil, + database: Database ) async throws(CloudKitError) -> AssetUploadToken { do { let tokenRequest = @@ -151,9 +157,12 @@ extension CloudKitService { tokens: [tokenRequest] ) + let client = try self.client(for: database) let response = try await client.uploadAssets( - path: createUploadAssetsPath( - containerIdentifier: containerIdentifier + path: Operations.uploadAssets.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database ), body: .json(requestBody) ) diff --git a/Sources/MistKit/Service/CloudKitService+AssetUpload.swift b/Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift similarity index 87% rename from Sources/MistKit/Service/CloudKitService+AssetUpload.swift rename to Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift index 9b654712..ff46f684 100644 --- a/Sources/MistKit/Service/CloudKitService+AssetUpload.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,7 @@ // public import Foundation +internal import Logging #if canImport(FoundationNetworking) import FoundationNetworking @@ -37,7 +38,7 @@ public import Foundation import OpenAPIURLSession #endif -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension CloudKitService { /// Upload binary data to a CloudKit asset upload URL /// @@ -54,7 +55,7 @@ extension CloudKitService { _ data: Data, to url: URL, using uploader: AssetUploader? = nil - ) async throws(CloudKitError) -> FieldValue.Asset { + ) async throws(CloudKitError) -> Asset { do { let uploadHandler = uploader ?? { data, url in @@ -80,18 +81,14 @@ extension CloudKitService { if let responseString = String( data: responseData, encoding: .utf8 ) { - MistKitLogger.logDebug( - "Asset upload response: \(responseString)", - logger: MistKitLogger.api, - shouldRedact: true - ) + Logger(subsystem: .api).debug("Asset upload response: \(responseString)") } let uploadResponse = try JSONDecoder().decode( AssetUploadResponse.self, from: responseData ) - return FieldValue.Asset( + return Asset( fileChecksum: uploadResponse.singleFile.fileChecksum, size: uploadResponse.singleFile.size, referenceChecksum: uploadResponse.singleFile.referenceChecksum, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift new file mode 100644 index 00000000..f31236f5 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift @@ -0,0 +1,124 @@ +// +// CloudKitService+Classification.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Helpers for tracking creates vs updates in `modifyRecords` responses. +/// +/// CloudKit's `/records/modify` endpoint does not include any indicator of +/// whether each operation produced a newly created record or updated an +/// existing one. The pattern in this extension implements the documented +/// pre-fetch + classify workaround: +/// +/// 1. Call `fetchExistingRecordNames(recordType:)` to discover which records +/// already exist. +/// 2. Build an `OperationClassification` from the proposed operations and the +/// existing names. +/// 3. Call `modifyRecords(_:classification:atomic:)` to perform the modify and +/// receive a `BatchSyncResult` with creates/updates/failures already +/// partitioned. +extension CloudKitService { + /// Fetch the set of record names that already exist for a record type. + /// + /// Used as the first step of the pre-fetch + classify pattern for tracking + /// creates vs updates in batch modify operations. Internally this calls + /// `queryRecords(recordType:limit:)` and projects the results down to a + /// `Set` of record names. + /// + /// - Important: This issues a single `queryRecords` call. CloudKit caps a + /// single response at 200 records, so for larger record types you must + /// paginate at the call site or use a custom query. + /// + /// - Parameters: + /// - recordType: The CloudKit record type to scan. + /// - limit: Optional maximum number of records to fetch (1-200). Defaults + /// to CloudKit's per-request maximum. + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`). + /// - Returns: Set of existing record names. + /// - Throws: `CloudKitError` if the underlying query fails. + public func fetchExistingRecordNames( + recordType: String, + limit: Int? = nil, + database: Database + ) async throws(CloudKitError) -> Set { + let result: QueryResult = try await queryRecords( + recordType: recordType, + limit: limit ?? Self.maxRecordsPerRequest, + database: database + ) + return Set(result.records.map(\.recordName)) + } + + /// Modify CloudKit records and partition the response into creates, + /// updates, failures, and unclassified records. + /// + /// This overload calls `modifyRecords(_:atomic:)` internally and then + /// uses the supplied `OperationClassification` to attribute each returned + /// `RecordInfo` to a category. It does not issue any extra CloudKit + /// requests beyond the modify itself. + /// + /// ## Example + /// ```swift + /// let existing = try await service.fetchExistingRecordNames(recordType: "Article") + /// let classification = OperationClassification( + /// operations: operations, + /// existingRecordNames: existing + /// ) + /// let result = try await service.modifyRecords( + /// operations, + /// classification: classification + /// ) + /// print("Created: \(result.createdCount)") + /// print("Updated: \(result.updatedCount)") + /// print("Failed: \(result.failedCount)") + /// ``` + /// + /// - Parameters: + /// - operations: Record operations to perform. + /// - classification: Pre-computed classification of operations as creates + /// vs updates, typically from `fetchExistingRecordNames(recordType:)`. + /// - atomic: When `true`, the entire batch fails if any single operation + /// fails (default: `false`). + /// - database: The CloudKit database scope to modify (`.public`, `.private`, `.shared`). + /// - Returns: A `BatchSyncResult` partitioning the response. + /// - Throws: `CloudKitError` if the modify request fails. + public func modifyRecords( + _ operations: [RecordOperation], + classification: OperationClassification, + atomic: Bool = false, + database: Database + ) async throws(CloudKitError) -> BatchSyncResult { + let records = try await modifyRecords( + operations, + atomic: atomic, + database: database + ) + return BatchSyncResult(records: records, classification: classification) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ClientDispatch.swift b/Sources/MistKit/CloudKitService/CloudKitService+ClientDispatch.swift new file mode 100644 index 00000000..51beb40c --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+ClientDispatch.swift @@ -0,0 +1,74 @@ +// +// CloudKitService+ClientDispatch.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI +internal import OpenAPIRuntime + +extension CloudKitService { + /// Resolve the token manager for an outgoing request and build a fresh + /// OpenAPI `Client` whose middleware chain authenticates against it. + /// + /// Called once per dispatched operation. The signing choice for `.public` + /// requests is carried by the `Database` value itself + /// (`.public(PublicAuthPreference)`); `.private` / `.shared` always use + /// web-auth. + /// + /// When the service was built with a caller-supplied `tokenManager:`, that + /// fixed manager is used regardless of `database`. Otherwise `Credentials` + /// resolves the manager via `makeTokenManager(for:)`. + /// + /// - Throws: `CloudKitError.missingCredentials` when `Credentials` cannot + /// satisfy the requested combination. + internal func client( + for database: Database + ) throws -> Client { + let tokenManager: any TokenManager + if let fixedTokenManager { + tokenManager = fixedTokenManager + } else if let credentials { + tokenManager = try credentials.makeTokenManager(for: database) + } else { + throw CloudKitError.missingCredentials( + database: database, + availability: .notConfigured, + reason: "service has neither credentials nor a fixed token manager" + ) + } + + return Client( + serverURL: CloudKitService.baseURL, + transport: transport, + middlewares: [ + AuthenticationMiddleware(tokenManager: tokenManager), + LoggingMiddleware(), + ] + ) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ErrorHandling.swift b/Sources/MistKit/CloudKitService/CloudKitService+ErrorHandling.swift new file mode 100644 index 00000000..507caa63 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+ErrorHandling.swift @@ -0,0 +1,110 @@ +// +// CloudKitService+ErrorHandling.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import Logging +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension CloudKitService { + /// Maps any error thrown from a CloudKit operation to a typed CloudKitError. + /// Includes detailed logging for decoding and network errors. + /// + /// - Parameters: + /// - error: The error to map + /// - context: A description of the operation (e.g., "fetchCaller") + /// - Returns: A CloudKitError representing the original error + internal func mapToCloudKitError( + _ error: any Error, + context: String + ) -> CloudKitError { + if let cloudKitError = error as? CloudKitError { + return cloudKitError + } + + // OpenAPIRuntime wraps transport-level errors in ClientError; unwrap to inspect the cause. + let inspected: any Error = + (error as? ClientError)?.underlyingError ?? error + + let apiLogger = Logger(subsystem: .api) + + if let decodingError = inspected as? DecodingError { + apiLogger.error("JSON decoding failed in \(context): \(decodingError)") + logDecodingErrorDetails(decodingError, logger: apiLogger) + return CloudKitError.decodingError(decodingError) + } + + if let urlError = inspected as? URLError { + Logger(subsystem: .network).error("Network error in \(context): \(urlError)") + return CloudKitError.networkError(urlError) + } + + apiLogger.error("Unexpected error in \(context): \(error)") + apiLogger.debug( + "Error type: \(type(of: error)), Description: \(String(reflecting: error))" + ) + return CloudKitError.underlyingError(error) + } + + /// Logs detailed context for a DecodingError to aid debugging. + private func logDecodingErrorDetails( + _ decodingError: DecodingError, + logger: Logger + ) { + switch decodingError { + case .keyNotFound(let key, let context): + logger.debug( + "Missing key: \(key), Context: \(context.debugDescription), Coding path: \(context.codingPath)" + ) + case .typeMismatch(let type, let context): + logger.debug( + """ + Type mismatch: expected \(type), Context: \(context.debugDescription), \ + Coding path: \(context.codingPath) + """ + ) + case .valueNotFound(let type, let context): + logger.debug( + """ + Value not found: expected \(type), Context: \(context.debugDescription), \ + Coding path: \(context.codingPath) + """ + ) + case .dataCorrupted(let context): + logger.debug( + "Data corrupted, Context: \(context.debugDescription), Coding path: \(context.codingPath)" + ) + @unknown default: + logger.debug("Unknown decoding error type") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift b/Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift new file mode 100644 index 00000000..8187a09d --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift @@ -0,0 +1,130 @@ +// +// CloudKitService+Initialization.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import OpenAPIRuntime + +#if canImport(FoundationNetworking) + internal import FoundationNetworking +#endif + +// MARK: - Credentials-based Initializer (All Platforms) + +extension CloudKitService { + /// Initialize CloudKit service with `Credentials`. + /// + /// Accepts any combination of `serverToServer` and `apiAuth` material. The + /// service does **not** carry a database — every operation that supports + /// multiple databases takes a `database:` argument at the call site, and + /// the appropriate token manager is resolved from `credentials` per call. + /// + /// Provide both credential sets when a single service must serve, for + /// example, public-database record operations via server-to-server signing + /// **and** user-identity routes (`fetchCaller`, `lookupUsers*`) via + /// web-auth — those are picked apart at dispatch time. + /// + /// Misconfiguration (no credential set covers a given call's database + + /// user-context combination) surfaces at call time as + /// `CloudKitError.missingCredentials`, not at construction. + internal init( + containerIdentifier: String, + credentials: Credentials, + environment: Environment = .development, + transport: any ClientTransport + ) { + self.containerIdentifier = containerIdentifier + self.environment = environment + self.credentials = credentials + self.fixedTokenManager = nil + self.transport = transport + } + + /// Initialize CloudKit service with a caller-supplied `TokenManager`. + /// + /// The supplied manager is used for **every** dispatched operation + /// regardless of database or whether the route requires user context. + /// Useful for tests and bespoke auth setups where the standard + /// `Credentials`-driven per-call selection isn't appropriate. + internal init( + containerIdentifier: String, + tokenManager: any TokenManager, + environment: Environment = .development, + transport: any ClientTransport + ) { + self.containerIdentifier = containerIdentifier + self.environment = environment + self.credentials = nil + self.fixedTokenManager = tokenManager + self.transport = transport + } +} + +// MARK: - URLSession Convenience Initializers (Non-WASI Platforms) + +#if !os(WASI) + internal import OpenAPIURLSession + + extension CloudKitService { + /// Initialize CloudKit service with `Credentials` using default + /// `URLSessionTransport`. + /// + /// Available on platforms that support URLSession. For WASI builds, use + /// the generic initializer that accepts a transport parameter. + public init( + containerIdentifier: String, + credentials: Credentials, + environment: Environment = .development + ) { + self.init( + containerIdentifier: containerIdentifier, + credentials: credentials, + environment: environment, + transport: URLSessionTransport() + ) + } + + /// Initialize CloudKit service with a custom `TokenManager` using default + /// `URLSessionTransport`. + /// + /// Available on platforms that support URLSession. For WASI builds, use + /// the generic initializer that accepts a transport parameter. + public init( + containerIdentifier: String, + tokenManager: any TokenManager, + environment: Environment = .development + ) { + self.init( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: environment, + transport: URLSessionTransport() + ) + } + } +#endif diff --git a/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift new file mode 100644 index 00000000..842869e9 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift @@ -0,0 +1,69 @@ +// +// CloudKitService+LookupOperations.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI + +extension CloudKitService { + /// Lookup records by record names + public func lookupRecords( + recordNames: [String], + desiredKeys: [String]? = nil, + database: Database + ) async throws(CloudKitError) -> [RecordInfo] { + do { + let client = try self.client(for: database) + let response = try await client.lookupRecords( + .init( + path: Operations.lookupRecords.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + records: recordNames.map { recordName in + .init( + recordName: recordName, + desiredKeys: desiredKeys + ) + } + ) + ) + ) + ) + + let lookupData: Components.Schemas.LookupResponse = + try await responseProcessor.processLookupRecordsResponse(response) + return lookupData.records?.compactMap { RecordInfo(from: $0) } ?? [] + } catch { + throw mapToCloudKitError(error, context: "lookupRecords") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift new file mode 100644 index 00000000..f931dc94 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift @@ -0,0 +1,123 @@ +// +// CloudKitService+ModifyZones.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if !os(WASI) + import OpenAPIURLSession +#endif + +extension CloudKitService { + /// Create or delete zones in the target database. + /// + /// CloudKit's `zones/modify` endpoint is only supported on the `.private` + /// and `.shared` databases — `.public` has only `_defaultZone`, so any + /// modify against it is rejected here without a network round-trip. + /// + /// - Parameters: + /// - operations: Non-empty array of create/delete operations. Each + /// operation's `ZoneID` must have a non-empty `zoneName`. + /// - database: Target database. Must not be `.public`. + /// - Returns: Array of `ZoneInfo` for the zones returned by the server. + /// - Throws: `CloudKitError` if validation fails or the request fails. + /// + /// Example - Create and delete in one batch: + /// ```swift + /// let zones = try await service.modifyZones( + /// [ + /// .create(ZoneID(zoneName: "Articles")), + /// .delete(ZoneID(zoneName: "Archive")) + /// ], + /// database: .private + /// ) + /// ``` + public func modifyZones( + _ operations: [ZoneOperation], + database: Database + ) async throws(CloudKitError) -> [ZoneInfo] { + guard !operations.isEmpty else { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: "operations cannot be empty" + ) + } + guard operations.allSatisfy({ !$0.zoneID.zoneName.isEmpty }) else { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: "operations contains a zone with an empty zoneName" + ) + } + if case .public = database { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: "modifyZones is not supported on the public database" + ) + } + + do { + let client = try self.client(for: database) + let response = try await client.modifyZones( + .init( + path: Operations.modifyZones.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + operations: operations.map { Components.Schemas.ZoneOperation(from: $0) } + ) + ) + ) + ) + + let zonesData: Components.Schemas.ZonesModifyResponse = + try await responseProcessor.processModifyZonesResponse(response) + + return zonesData.zones?.compactMap { zone in + guard let zoneID = zone.zoneID else { + return nil + } + return ZoneInfo( + zoneName: zoneID.zoneName ?? "Unknown", + ownerRecordName: zoneID.ownerName, + capabilities: [] + ) + } ?? [] + } catch { + throw mapToCloudKitError(error, context: "modifyZones") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift new file mode 100644 index 00000000..cdc580a4 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift @@ -0,0 +1,212 @@ +// +// CloudKitService+Operations.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if !os(WASI) + import OpenAPIURLSession +#endif + +extension CloudKitService { + /// Query records from the default zone + /// + /// Queries CloudKit records with optional filtering and sorting. + /// Supports all CloudKit filter operations (equals, comparisons, + /// string matching, list operations) and field-based sorting. + /// + /// - Parameters: + /// - recordType: The type of records to query (must not be empty) + /// - filters: Optional array of filters to apply to the query + /// - sortBy: Optional array of sort descriptors + /// - limit: Maximum number of records to return + /// (1-200, defaults to `defaultQueryLimit`) + /// - desiredKeys: Optional array of field names to fetch + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) + /// - Returns: Array of matching records + /// - Throws: CloudKitError if validation fails or the request fails + /// + /// # Example: Basic Query + /// ```swift + /// let articles = try await service.queryRecords( + /// recordType: "Article" + /// ) + /// ``` + /// + /// # Example: Query with Filters + /// ```swift + /// let recentArticles = try await service.queryRecords( + /// recordType: "Article", + /// filters: [ + /// .greaterThan("publishedDate", .date(oneWeekAgo)), + /// .equals("status", .string("published")) + /// ], + /// limit: 50 + /// ) + /// ``` + /// + /// # Example: Query with Sorting + /// ```swift + /// let sortedArticles = try await service.queryRecords( + /// recordType: "Article", + /// sortBy: [.descending("publishedDate")], + /// limit: 20 + /// ) + /// ``` + /// + /// - Note: For large result sets, consider using pagination + /// with `continuationMarker` or `queryAllRecords` + @available( + *, deprecated, + message: "Use queryRecords -> QueryResult for pagination, or queryAllRecords to auto-paginate." + ) + public func queryRecords( + recordType: String, + filters: [QueryFilter]? = nil, + sortBy: [QuerySort]? = nil, + limit: Int? = nil, + desiredKeys: [String]? = nil, + database: Database + ) async throws(CloudKitError) -> [RecordInfo] { + let result: QueryResult = try await queryRecords( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: limit, + desiredKeys: desiredKeys, + continuationMarker: nil, + database: database + ) + return result.records + } + + /// Query records from the default zone with pagination support + /// + /// Queries CloudKit records with optional filtering, sorting, and pagination. + /// Returns a `QueryResult` containing both the matching records and + /// a `continuationMarker` for fetching subsequent pages. + /// + /// - Parameters: + /// - recordType: The type of records to query (must not be empty) + /// - filters: Optional array of filters to apply to the query + /// - sortBy: Optional array of sort descriptors + /// - limit: Maximum number of records to return + /// (1-200, defaults to `defaultQueryLimit`) + /// - desiredKeys: Optional array of field names to fetch + /// - continuationMarker: Marker from a previous `QueryResult` + /// to fetch the next page of results + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) + /// - Returns: A `QueryResult` with matching records and an optional + /// continuation marker for the next page + /// - Throws: CloudKitError if validation fails or the request fails + /// + /// # Example: Paginated Query + /// ```swift + /// var marker: String? = nil + /// repeat { + /// let result: QueryResult = try await service.queryRecords( + /// recordType: "Article", + /// limit: 50, + /// continuationMarker: marker + /// ) + /// process(result.records) + /// marker = result.continuationMarker + /// } while marker != nil + /// ``` + public func queryRecords( + recordType: String, + filters: [QueryFilter]? = nil, + sortBy: [QuerySort]? = nil, + limit: Int? = nil, + desiredKeys: [String]? = nil, + continuationMarker: String? = nil, + database: Database + ) async throws(CloudKitError) -> QueryResult { + let effectiveLimit = limit ?? defaultQueryLimit + + guard !recordType.isEmpty else { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: "recordType cannot be empty" + ) + } + + guard effectiveLimit > 0 && effectiveLimit <= 200 else { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: + "limit must be between 1 and 200, got \(effectiveLimit)" + ) + } + + let componentsFilters = filters?.map { + Components.Schemas.Filter(from: $0) + } + let componentsSorts = sortBy?.map { + Components.Schemas.Sort(from: $0) + } + + do { + let client = try self.client(for: database) + let response = try await client.queryRecords( + .init( + path: Operations.queryRecords.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + zoneID: .init(zoneName: "_defaultZone"), + resultsLimit: effectiveLimit, + query: .init( + recordType: recordType, + filterBy: componentsFilters, + sortBy: componentsSorts + ), + desiredKeys: desiredKeys, + continuationMarker: continuationMarker + ) + ) + ) + ) + + let recordsData: Components.Schemas.QueryResponse = + try await responseProcessor.processQueryRecordsResponse(response) + return QueryResult(from: recordsData) + } catch { + throw mapToCloudKitError(error, context: "queryRecords") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift new file mode 100644 index 00000000..3001b042 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift @@ -0,0 +1,110 @@ +// +// CloudKitService+QueryPagination.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension CloudKitService { + /// Query all records, handling pagination automatically + /// + /// Convenience method that automatically fetches all matching records + /// by following continuation markers and making multiple requests + /// if needed. + /// + /// - Parameters: + /// - recordType: The type of records to query (must not be empty) + /// - filters: Optional array of filters to apply to the query + /// - sortBy: Optional array of sort descriptors + /// - pageSize: Maximum number of records per page + /// (1-200, defaults to `defaultQueryLimit`) + /// - desiredKeys: Optional array of field names to fetch + /// - maxPages: Maximum number of pages to fetch before throwing + /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) + /// - Returns: Array of all matching records across all pages + /// - Throws: `CloudKitError`. When `maxPages` is exceeded, throws + /// `.paginationLimitExceeded(maxPages:records:)` whose `records` + /// payload contains every record collected before the cap was hit, + /// so callers can resume or surface partial results. + /// + /// - Warning: Stops early if the server returns the same + /// continuation marker with no new records (stuck-marker + /// scenario). + public func queryAllRecords( + recordType: String, + filters: [QueryFilter]? = nil, + sortBy: [QuerySort]? = nil, + pageSize: Int? = nil, + desiredKeys: [String]? = nil, + maxPages: Int = 1_000, + database: Database + ) async throws(CloudKitError) -> [RecordInfo] { + var allRecords: [RecordInfo] = [] + var currentMarker: String? + var pageCount = 0 + + repeat { + guard pageCount < maxPages else { + throw CloudKitError.paginationLimitExceeded( + maxPages: maxPages, + records: allRecords + ) + } + + do { + try Task.checkCancellation() + } catch { + throw mapToCloudKitError(error, context: "queryAllRecords") + } + + let result: QueryResult = try await queryRecords( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: pageSize, + desiredKeys: desiredKeys, + continuationMarker: currentMarker, + database: database + ) + + // Stuck-marker detection + if result.records.isEmpty + && result.continuationMarker != nil + && result.continuationMarker == currentMarker + { + break + } + + allRecords.append(contentsOf: result.records) + currentMarker = result.continuationMarker + pageCount += 1 + } while currentMarker != nil + + return allRecords + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift new file mode 100644 index 00000000..5c36f93f --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift @@ -0,0 +1,79 @@ +// +// CloudKitService+RecordManaging.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// CloudKitService conformance to RecordManaging protocol +/// +/// This extension makes CloudKitService compatible with the generic RecordManaging +/// operations, enabling protocol-oriented patterns for CloudKit operations. +extension CloudKitService: RecordManaging { + /// Query records of a specific type from CloudKit (deprecated single-page form) + /// + /// `RecordManaging` is a database-agnostic abstraction predating per-call + /// `PublicAuthPreference`; this conformance targets the public database + /// with `.requires(.serverToServer)` to preserve the previous "S2S when + /// configured" behavior. Callers who need different attribution should + /// call `CloudKitService` directly with an explicit `Database` value. + @available( + *, deprecated, + message: "Silently truncates at one page. Use queryAllRecords or queryRecords -> QueryResult." + ) + public func queryRecords(recordType: String) async throws -> [RecordInfo] { + let result: QueryResult = try await self.queryRecords( + recordType: recordType, + filters: nil, + sortBy: nil, + limit: 200, + desiredKeys: nil, + continuationMarker: nil, + database: .public(.prefers(.serverToServer)) + ) + return result.records + } + + /// Execute a batch of record operations via modify + public func executeBatchOperations(_ operations: [RecordOperation]) async throws { + _ = try await self.modifyRecords( + operations, + database: .public(.prefers(.serverToServer)) + ) + } + + /// Query all records of a specific type, automatically paginating + public func queryAllRecords(recordType: String) async throws -> [RecordInfo] { + try await self.queryAllRecords( + recordType: recordType, + filters: nil, + sortBy: nil, + pageSize: nil, + database: .public(.prefers(.serverToServer)) + ) + } +} diff --git a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift similarity index 81% rename from Sources/MistKit/Service/CloudKitService+SyncOperations.swift rename to Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift index de4b158b..ba3fc145 100644 --- a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,7 @@ // import Foundation +internal import MistKitOpenAPI import OpenAPIRuntime #if canImport(FoundationNetworking) @@ -38,7 +39,6 @@ import OpenAPIRuntime import OpenAPIURLSession #endif -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { /// Fetch record changes since a sync token /// @@ -51,6 +51,7 @@ extension CloudKitService { /// (defaults to _defaultZone) /// - syncToken: Optional token from previous fetch (nil = initial fetch) /// - resultsLimit: Optional maximum number of records (1-200) + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) /// - Returns: RecordChangesResult containing changed records /// and new sync token /// - Throws: CloudKitError if the fetch fails @@ -80,7 +81,8 @@ extension CloudKitService { public func fetchRecordChanges( zoneID: ZoneID? = nil, syncToken: String? = nil, - resultsLimit: Int? = nil + resultsLimit: Int? = nil, + database: Database ) async throws(CloudKitError) -> RecordChangesResult { if let limit = resultsLimit { guard limit > 0 && limit <= 200 else { @@ -95,10 +97,13 @@ extension CloudKitService { let effectiveZoneID = zoneID ?? .defaultZone do { + let client = try self.client(for: database) let response = try await client.fetchRecordChanges( .init( - path: createFetchRecordChangesPath( - containerIdentifier: containerIdentifier + path: Operations.fetchRecordChanges.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database ), body: .json( .init( @@ -131,8 +136,13 @@ extension CloudKitService { /// (defaults to _defaultZone) /// - syncToken: Optional token from previous fetch (nil = initial fetch) /// - resultsLimit: Optional maximum records per request (1-200) + /// - maxPages: Maximum number of pages to fetch before throwing + /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) /// - Returns: Array of all changed records and final sync token - /// - Throws: CloudKitError if any fetch fails + /// - Throws: `CloudKitError`. When `maxPages` is exceeded, throws + /// `.paginationLimitExceeded(maxPages:records:)` whose `records` + /// payload contains every record collected before the cap was hit. /// /// Example: /// ```swift @@ -149,24 +159,28 @@ extension CloudKitService { /// with manual pagination for better memory control. /// - Warning: This method will stop early if the server repeatedly returns /// `moreComing: true` with no records and the same sync token - /// (stuck-token scenario), or if the page count exceeds 1000. + /// (stuck-token scenario). /// - Note: Makes sequential requests with no backoff or cooperative /// cancellation between pages. For fine-grained control, /// use `fetchRecordChanges(syncToken:)` directly. public func fetchAllRecordChanges( zoneID: ZoneID? = nil, syncToken: String? = nil, - resultsLimit: Int? = nil + resultsLimit: Int? = nil, + maxPages: Int = 1_000, + database: Database ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { var allRecords: [RecordInfo] = [] var currentToken = syncToken - var moreComing = true + var moreComing = false var pageCount = 0 - let maxPages = 1_000 - while moreComing { + repeat { guard pageCount < maxPages else { - throw CloudKitError.invalidResponse + throw CloudKitError.paginationLimitExceeded( + maxPages: maxPages, + records: allRecords + ) } do { @@ -178,9 +192,11 @@ extension CloudKitService { let result = try await fetchRecordChanges( zoneID: zoneID, syncToken: currentToken, - resultsLimit: resultsLimit + resultsLimit: resultsLimit, + database: database ) + // Stuck-token detection if result.records.isEmpty && result.moreComing && result.syncToken == currentToken { break } @@ -193,7 +209,7 @@ extension CloudKitService { currentToken = result.syncToken moreComing = result.moreComing pageCount += 1 - } + } while moreComing return (allRecords, currentToken) } diff --git a/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift new file mode 100644 index 00000000..ebec3824 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift @@ -0,0 +1,181 @@ +// +// CloudKitService+UserOperations.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if !os(WASI) + import OpenAPIURLSession +#endif + +extension CloudKitService { + /// Fetch the caller's (current authenticated user's) information. + /// + /// Hits CloudKit's `users/caller` endpoint, which replaces the deprecated + /// `users/current`. Routed against the public database with web-auth + /// credentials — calling against private/shared returns + /// `BAD_REQUEST: endpoint not applicable in the database type`, so the + /// database is fixed in the path and not exposed to callers. The service's + /// `Credentials` must include an `apiAuth` with a `webAuthToken`. + public func fetchCaller() async throws(CloudKitError) -> UserInfo { + do { + let client = try self.client(for: .public(.requires(.webAuth))) + let response = try await client.getCaller( + .init( + path: Operations.getCaller.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: .public(.requires(.webAuth)) + ) + ) + ) + + let userData: Components.Schemas.UserResponse = + try await responseProcessor.processGetCallerResponse(response) + return UserInfo(from: userData) + } catch { + throw mapToCloudKitError(error, context: "fetchCaller") + } + } + + /// Fetch the current authenticated user's information. + @available( + *, deprecated, renamed: "fetchCaller", + message: "users/current is deprecated by Apple. Use fetchCaller() instead." + ) + public func fetchCurrentUser() async throws(CloudKitError) -> UserInfo { + try await fetchCaller() + } + + /// Look up user identities by email address. + /// + /// Hits CloudKit's POST `users/lookup/email` endpoint. Each requested email + /// returns at most one identity in the result array. Routed against the + /// public database with web-auth credentials. + public func lookupUsersByEmail( + _ emails: [String] + ) async throws(CloudKitError) -> [UserIdentity] { + do { + let client = try self.client(for: .public(.requires(.webAuth))) + let response = try await client.lookupUsersByEmail( + .init( + path: Operations.lookupUsersByEmail.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: .public(.requires(.webAuth)) + ), + body: .json( + .init(users: emails.map { .init(emailAddress: $0) }) + ) + ) + ) + + let discoverData: Components.Schemas.DiscoverResponse = + try await responseProcessor.processLookupUsersByEmailResponse(response) + return discoverData.users?.map(UserIdentity.init(from:)) ?? [] + } catch { + throw mapToCloudKitError(error, context: "lookupUsersByEmail") + } + } + + /// Look up user identities by record name (CloudKit user record ID). + /// + /// Hits CloudKit's POST `users/lookup/id` endpoint. Routed against the + /// public database with web-auth credentials. + public func lookupUsersByRecordName( + _ recordNames: [String] + ) async throws(CloudKitError) -> [UserIdentity] { + do { + let client = try self.client(for: .public(.requires(.webAuth))) + let response = try await client.lookupUsersByRecordName( + .init( + path: Operations.lookupUsersByRecordName.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: .public(.requires(.webAuth)) + ), + body: .json( + .init(users: recordNames.map { .init(userRecordName: $0) }) + ) + ) + ) + + let discoverData: Components.Schemas.DiscoverResponse = + try await responseProcessor.processLookupUsersByRecordNameResponse(response) + return discoverData.users?.map(UserIdentity.init(from:)) ?? [] + } catch { + throw mapToCloudKitError(error, context: "lookupUsersByRecordName") + } + } + + /// Discover user identities by email addresses or record names. + /// + /// Hits CloudKit's POST `users/discover` endpoint. Routed against the public + /// database with web-auth credentials. + public func discoverUserIdentities( + lookupInfos: [UserIdentityLookupInfo] + ) async throws(CloudKitError) -> [UserIdentity] { + do { + let client = try self.client(for: .public(.requires(.webAuth))) + let response = try await client.discoverUserIdentities( + .init( + path: Operations.discoverUserIdentities.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: .public(.requires(.webAuth)) + ), + body: .json( + .init( + lookupInfos: lookupInfos.map { + .init( + emailAddress: $0.emailAddress, + phoneNumber: $0.phoneNumber, + userRecordName: $0.userRecordName + ) + } + ) + ) + ) + ) + + let discoverData: Components.Schemas.DiscoverResponse = + try await responseProcessor.processDiscoverUserIdentitiesResponse( + response + ) + return discoverData.users?.map(UserIdentity.init(from:)) ?? [] + } catch { + throw mapToCloudKitError(error, context: "discoverUserIdentities") + } + } +} diff --git a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift similarity index 76% rename from Sources/MistKit/Service/CloudKitService+WriteOperations.swift rename to Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift index faf05d79..a34fff9f 100644 --- a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,7 @@ // import Foundation +internal import MistKitOpenAPI import OpenAPIRuntime #if canImport(FoundationNetworking) @@ -38,20 +39,25 @@ import OpenAPIRuntime import OpenAPIURLSession #endif -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { /// Modify (create, update, or delete) CloudKit records - /// - Parameter operations: Array of record operations to perform + /// - Parameters: + /// - operations: Array of record operations to perform + /// - atomic: When true, the entire batch fails if any single operation fails (default: false) + /// - database: The CloudKit database scope to modify (`.public`, `.private`, `.shared`) /// - Returns: Array of RecordInfo for the modified records /// - Throws: CloudKitError if the operation fails public func modifyRecords( - _ operations: [RecordOperation] + _ operations: [RecordOperation], + atomic: Bool = false, + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { - let apiOperations = operations.map { - Components.Schemas.RecordOperation(from: $0) + let apiOperations = try operations.map { + try Components.Schemas.RecordOperation(from: $0) } + let client = try self.client(for: database) let response = try await client.modifyRecords( .init( path: .init( @@ -63,7 +69,7 @@ extension CloudKitService { body: .json( .init( operations: apiOperations, - atomic: false + atomic: atomic ) ) ) @@ -86,12 +92,14 @@ extension CloudKitService { /// - recordType: The type of record to create /// - recordName: Optional unique record name /// - fields: Dictionary of field names to FieldValue + /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the created record /// - Throws: CloudKitError if the operation fails public func createRecord( recordType: String, recordName: String? = nil, - fields: [String: FieldValue] + fields: [String: FieldValue], + database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( recordType: recordType, @@ -99,7 +107,7 @@ extension CloudKitService { fields: fields ) - let results = try await modifyRecords([operation]) + let results = try await modifyRecords([operation], database: database) guard let record = results.first else { throw CloudKitError.invalidResponse } @@ -112,13 +120,15 @@ extension CloudKitService { /// - recordName: The unique record name /// - fields: Dictionary of field names to FieldValue /// - recordChangeTag: Optional change tag for optimistic locking + /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the updated record /// - Throws: CloudKitError if the operation fails public func updateRecord( recordType: String, recordName: String, fields: [String: FieldValue], - recordChangeTag: String? = nil + recordChangeTag: String? = nil, + database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( recordType: recordType, @@ -127,7 +137,7 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - let results = try await modifyRecords([operation]) + let results = try await modifyRecords([operation], database: database) guard let record = results.first else { throw CloudKitError.invalidResponse } @@ -139,11 +149,13 @@ extension CloudKitService { /// - recordType: The type of record to delete /// - recordName: The unique record name /// - recordChangeTag: Optional change tag for optimistic locking + /// - database: The CloudKit database scope to delete from (`.public`, `.private`, `.shared`) /// - Throws: CloudKitError if the operation fails public func deleteRecord( recordType: String, recordName: String, - recordChangeTag: String? = nil + recordChangeTag: String? = nil, + database: Database ) async throws(CloudKitError) { let operation = RecordOperation.delete( recordType: recordType, @@ -151,6 +163,6 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - _ = try await modifyRecords([operation]) + _ = try await modifyRecords([operation], database: database) } } diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift new file mode 100644 index 00000000..9e32b85a --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift @@ -0,0 +1,206 @@ +// +// CloudKitService+ZoneOperations.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if !os(WASI) + import OpenAPIURLSession +#endif + +extension CloudKitService { + /// List zones in the target database. + /// + /// > Note: The default is `.private` because the public database only + /// > contains the default zone (`_defaultZone`); listing zones against + /// > `.public` is degenerate. Pass `.shared` for the shared database. + public func listZones( + database: Database = .private + ) async throws(CloudKitError) -> [ZoneInfo] { + do { + let client = try self.client(for: database) + let response = try await client.listZones( + .init( + path: Operations.listZones.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ) + ) + ) + + let zonesData: Components.Schemas.ZonesListResponse = + try await responseProcessor.processListZonesResponse(response) + return zonesData.zones?.compactMap { zone in + guard let zoneID = zone.zoneID else { + return nil + } + return ZoneInfo( + zoneName: zoneID.zoneName ?? "Unknown", + ownerRecordName: zoneID.ownerName, + capabilities: [] + ) + } ?? [] + } catch { + throw mapToCloudKitError(error, context: "listZones") + } + } + + /// Lookup specific zones by their IDs + /// + /// Fetches detailed information about multiple zones in a single request. + /// Unlike listZones which returns all zones, this operation retrieves + /// specific zones identified by their zone IDs. + /// + /// - Parameters: + /// - zoneIDs: Array of zone identifiers to lookup + /// - database: The CloudKit database scope to query (defaults to `.private`) + /// - Returns: Array of ZoneInfo objects for the requested zones + /// - Throws: CloudKitError if the lookup fails + /// + /// Example: + /// ```swift + /// let zones = try await service.lookupZones( + /// zoneIDs: [ + /// ZoneID(zoneName: "Articles", ownerName: nil), + /// ZoneID(zoneName: "Images", ownerName: nil) + /// ] + /// ) + /// ``` + public func lookupZones( + zoneIDs: [ZoneID], + database: Database = .private + ) async throws(CloudKitError) -> [ZoneInfo] { + guard !zoneIDs.isEmpty else { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: "zoneIDs cannot be empty" + ) + } + guard zoneIDs.allSatisfy({ !$0.zoneName.isEmpty }) else { + throw CloudKitError.httpErrorWithRawResponse( + statusCode: 400, + rawResponse: "zoneIDs contains a zone with an empty zoneName" + ) + } + + do { + let client = try self.client(for: database) + let response = try await client.lookupZones( + .init( + path: Operations.lookupZones.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + zones: zoneIDs.map { Components.Schemas.ZoneID(from: $0) } + ) + ) + ) + ) + + let zonesData: Components.Schemas.ZonesLookupResponse = + try await responseProcessor.processLookupZonesResponse(response) + + return zonesData.zones?.compactMap { zone in + guard let zoneID = zone.zoneID else { + return nil + } + return ZoneInfo( + zoneName: zoneID.zoneName ?? "Unknown", + ownerRecordName: zoneID.ownerName, + capabilities: [] + ) + } ?? [] + } catch { + throw mapToCloudKitError(error, context: "lookupZones") + } + } + + /// Fetch zone changes since a sync token + /// + /// Retrieves all zones that have changed since the provided sync token. + /// Use this for efficient incremental sync at the zone level. + /// + /// - Parameters: + /// - syncToken: Optional token from previous fetch (nil = initial fetch) + /// - database: The CloudKit database scope to query (defaults to `.private`) + /// - Returns: ZoneChangesResult containing changed zones and new sync token + /// - Throws: CloudKitError if the fetch fails + /// + /// Example - Initial Sync: + /// ```swift + /// let result = try await service.fetchZoneChanges() + /// // Store result.syncToken for next fetch + /// processZones(result.zones) + /// ``` + /// + /// Example - Incremental Sync: + /// ```swift + /// let result = try await service.fetchZoneChanges( + /// syncToken: previousToken + /// ) + /// ``` + public func fetchZoneChanges( + syncToken: String? = nil, + database: Database = .private + ) async throws(CloudKitError) -> ZoneChangesResult { + do { + let client = try self.client(for: database) + let response = try await client.fetchZoneChanges( + .init( + path: Operations.fetchZoneChanges.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + syncToken: syncToken + ) + ) + ) + ) + + let changesData: Components.Schemas.ZoneChangesResponse = + try await responseProcessor.processFetchZoneChangesResponse(response) + + return ZoneChangesResult(from: changesData) + } catch { + throw mapToCloudKitError(error, context: "fetchZoneChanges") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService.swift b/Sources/MistKit/CloudKitService/CloudKitService.swift new file mode 100644 index 00000000..ca3dffac --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService.swift @@ -0,0 +1,87 @@ +// +// CloudKitService.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +internal import OpenAPIRuntime + +#if canImport(FoundationNetworking) + internal import FoundationNetworking +#endif + +#if !os(WASI) + internal import OpenAPIURLSession +#endif + +/// Service for interacting with CloudKit Web Services. +/// +/// `CloudKitService` is configured with a CloudKit container identifier, an +/// `Environment`, and a `Credentials` value that may carry server-to-server +/// material, API/web-auth material, or both. The database to target is chosen +/// **per call** on each operation that supports multiple databases; user-identity +/// endpoints (e.g. `fetchCaller`) hard-code `.public` since CloudKit only +/// accepts those routes against the public database. +/// +/// At dispatch time the service resolves the appropriate token manager from +/// `Credentials` based on the target database and whether the operation +/// requires user-context auth. A single service can therefore serve, for +/// example, public-database record reads via server-to-server signing **and** +/// `fetchCaller` via web-auth from one fully-populated `Credentials`. +public struct CloudKitService: Sendable { + // swiftlint:disable force_unwrapping + /// The base URL for CloudKit Web Services. + public static let baseURL = URL(string: "https://api.apple-cloudkit.com")! + // swiftlint:enable force_unwrapping + + /// CloudKit's maximum number of records returned per query/modify request. + internal static let maxRecordsPerRequest: Int = 200 + + /// The CloudKit container identifier + public let containerIdentifier: String + /// The CloudKit environment (development or production) + public let environment: Environment + + /// Default limit for query operations (1-200, default: 100) + internal let defaultQueryLimit: Int = 100 + + internal let responseProcessor = CloudKitResponseProcessor() + + /// Resolved at construction from `Credentials`. `nil` when this service + /// was built with a caller-supplied fixed `tokenManager`. + internal let credentials: Credentials? + + /// Caller-supplied token manager that overrides per-call resolution. + /// Set by the bespoke `tokenManager:` initializer for tests and special + /// cases; otherwise `nil`. + internal let fixedTokenManager: (any TokenManager)? + + /// Transport used for every dispatched request. Each operation builds a + /// fresh OpenAPI `Client` against this transport with the resolved token + /// manager wired into its middleware chain. + internal let transport: any ClientTransport +} diff --git a/Sources/MistKit/CustomFieldValue.CustomFieldValuePayload.swift b/Sources/MistKit/CustomFieldValue.CustomFieldValuePayload.swift deleted file mode 100644 index 7227ad0b..00000000 --- a/Sources/MistKit/CustomFieldValue.CustomFieldValuePayload.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// CustomFieldValue.CustomFieldValuePayload.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -extension CustomFieldValue.CustomFieldValuePayload { - /// Initialize from decoder - public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - - if let value = try Self.decodeBasicPayloadTypes(from: container) { - self = value - return - } - - if let value = try Self.decodeComplexPayloadTypes(from: container) { - self = value - return - } - - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Could not decode FieldValuePayload" - ) - ) - } - - /// Decode basic payload types (string, int64, double, boolean) - private static func decodeBasicPayloadTypes( - from container: any SingleValueDecodingContainer - ) throws -> CustomFieldValue.CustomFieldValuePayload? { - if let value = try? container.decode(String.self) { - return .stringValue(value) - } - if let value = try? container.decode(Int.self) { - return .int64Value(value) - } - if let value = try? container.decode(Double.self) { - return .doubleValue(value) - } - return nil - } - - /// Decode complex payload types (asset, location, reference, list) - private static func decodeComplexPayloadTypes( - from container: any SingleValueDecodingContainer - ) throws -> CustomFieldValue.CustomFieldValuePayload? { - if let value = try? container.decode(Components.Schemas.AssetValue.self) { - return .assetValue(value) - } - if let value = try? container.decode(Components.Schemas.LocationValue.self) { - return .locationValue(value) - } - if let value = try? container.decode(Components.Schemas.ReferenceValue.self) { - return .referenceValue(value) - } - if let value = try? container.decode([CustomFieldValue.CustomFieldValuePayload].self) { - return .listValue(value) - } - return nil - } - - /// Encode to encoder - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try encodeValue(to: &container) - } - - // swiftlint:disable:next cyclomatic_complexity - private func encodeValue(to container: inout any SingleValueEncodingContainer) throws { - switch self { - case .stringValue(let val), .bytesValue(let val): - try container.encode(val) - case .int64Value(let val): - try container.encode(val) - case .booleanValue(let val): - // CloudKit represents booleans as int64 (0 or 1) - try container.encode(val ? 1 : 0) - case .doubleValue(let val), .dateValue(let val): - try encodeNumericValue(val, to: &container) - case .locationValue(let val): - try container.encode(val) - case .referenceValue(let val): - try container.encode(val) - case .assetValue(let val): - try container.encode(val) - case .listValue(let val): - try container.encode(val) - } - } - - private func encodeNumericValue( - _ value: T, to container: inout any SingleValueEncodingContainer - ) throws { - try container.encode(value) - } -} diff --git a/Sources/MistKit/CustomFieldValue.swift b/Sources/MistKit/CustomFieldValue.swift deleted file mode 100644 index e2c66a63..00000000 --- a/Sources/MistKit/CustomFieldValue.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// CustomFieldValue.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import OpenAPIRuntime - -/// Custom implementation of FieldValue with proper ASSETID handling -internal struct CustomFieldValue: Codable, Hashable, Sendable { - /// Field type payload for CloudKit fields - public enum FieldTypePayload: String, Codable, Hashable, Sendable, CaseIterable { - case string = "STRING" - case int64 = "INT64" - case double = "DOUBLE" - case bytes = "BYTES" - case reference = "REFERENCE" - case asset = "ASSET" - case assetid = "ASSETID" - case location = "LOCATION" - case timestamp = "TIMESTAMP" - case list = "LIST" - } - - /// Custom field value payload supporting various CloudKit types - public enum CustomFieldValuePayload: Codable, Hashable, Sendable { - case stringValue(String) - case int64Value(Int) - case doubleValue(Double) - case bytesValue(String) - case dateValue(Double) - case booleanValue(Bool) - case locationValue(Components.Schemas.LocationValue) - case referenceValue(Components.Schemas.ReferenceValue) - case assetValue(Components.Schemas.AssetValue) - case listValue([CustomFieldValuePayload]) - } - - internal enum CodingKeys: String, CodingKey { - case value - case type - } - - private static let fieldTypeDecoders: - [FieldTypePayload: - @Sendable (KeyedDecodingContainer) throws -> - CustomFieldValuePayload] = [ - .string: { .stringValue(try $0.decode(String.self, forKey: .value)) }, - .int64: { .int64Value(try $0.decode(Int.self, forKey: .value)) }, - .double: { .doubleValue(try $0.decode(Double.self, forKey: .value)) }, - .bytes: { .bytesValue(try $0.decode(String.self, forKey: .value)) }, - .reference: { - .referenceValue(try $0.decode(Components.Schemas.ReferenceValue.self, forKey: .value)) - }, - .asset: { .assetValue(try $0.decode(Components.Schemas.AssetValue.self, forKey: .value)) }, - .assetid: { - .assetValue(try $0.decode(Components.Schemas.AssetValue.self, forKey: .value)) - }, - .location: { - .locationValue(try $0.decode(Components.Schemas.LocationValue.self, forKey: .value)) - }, - .timestamp: { .dateValue(try $0.decode(Double.self, forKey: .value)) }, - .list: { .listValue(try $0.decode([CustomFieldValuePayload].self, forKey: .value)) }, - ] - - private static let defaultDecoder: - @Sendable (KeyedDecodingContainer) throws -> CustomFieldValuePayload = { - .stringValue(try $0.decode(String.self, forKey: .value)) - } - - /// The field value payload - internal let value: CustomFieldValuePayload - /// The field type - internal let type: FieldTypePayload? - - /// Internal initializer for constructing field values programmatically - internal init(value: CustomFieldValuePayload, type: FieldTypePayload?) { - self.value = value - self.type = type - } - - internal init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fieldType = try container.decodeIfPresent(FieldTypePayload.self, forKey: .type) - self.type = fieldType - - if let fieldType = fieldType { - self.value = try Self.decodeTypedValue(from: container, type: fieldType) - } else { - self.value = try Self.decodeFallbackValue(from: container) - } - } - - private static func decodeTypedValue( - from container: KeyedDecodingContainer, - type fieldType: FieldTypePayload - ) throws -> CustomFieldValuePayload { - let decoder = fieldTypeDecoders[fieldType] ?? defaultDecoder - return try decoder(container) - } - - private static func decodeFallbackValue( - from container: KeyedDecodingContainer - ) throws -> CustomFieldValuePayload { - let valueContainer = try container.superDecoder(forKey: .value) - return try CustomFieldValuePayload(from: valueContainer) - } - - // swiftlint:disable:next cyclomatic_complexity - private static func encodeValue( - _ value: CustomFieldValuePayload, - to container: inout KeyedEncodingContainer - ) throws { - switch value { - case .stringValue(let val), .bytesValue(let val): - try container.encode(val, forKey: .value) - case .int64Value(let val): - try container.encode(val, forKey: .value) - case .doubleValue(let val): - try container.encode(val, forKey: .value) - case .dateValue(let val): - try container.encode(val, forKey: .value) - case .booleanValue(let val): - // CloudKit represents booleans as int64 (0 or 1) - try container.encode(val ? 1 : 0, forKey: .value) - case .locationValue(let val): - try container.encode(val, forKey: .value) - case .referenceValue(let val): - try container.encode(val, forKey: .value) - case .assetValue(let val): - try container.encode(val, forKey: .value) - case .listValue(let val): - try container.encode(val, forKey: .value) - } - } - - internal func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(type, forKey: .type) - try Self.encodeValue(value, to: &container) - } -} diff --git a/Sources/MistKit/Database.swift b/Sources/MistKit/Database.swift deleted file mode 100644 index ac650c82..00000000 --- a/Sources/MistKit/Database.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Database.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// CloudKit database types -public enum Database: String, Sendable { - case `public` - case `private` - case shared -} diff --git a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md index 705f22b2..8b98a346 100644 --- a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md +++ b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md @@ -1,912 +1,260 @@ -# MistKit Abstraction Layer Architecture +# Abstraction Layer Architecture -A comprehensive guide to MistKit's Swift abstraction layer built on top of the swift-openapi-generator client, showcasing modern Swift patterns and concurrency features. +The hand-written Swift surface on top of MistKit's generated OpenAPI client — how the layers split responsibility, why the boundaries land where they do, and how Swift 6 concurrency shapes each one. ## Overview -MistKit provides a friendly Swift abstraction layer that wraps the generated OpenAPI client code, offering improved ergonomics, type safety, and developer experience while leveraging modern Swift 6 concurrency features. This article explores the architectural patterns, design decisions, and implementation details of this abstraction layer. +The generated OpenAPI code is a faithful, namespaced translation of `openapi.yaml`. It is correct but verbose: every operation is a nested `Operations..Input` / `Output` enum tree, every response is a status-code enum, every error case requires explicit unwrapping. MistKit's abstraction layer turns that into the surface most callers actually want: typed records, async iteration, structured errors, and three authentication schemes that don't leak through to call sites. -## Architecture Philosophy +This article describes how that layer is organised. For the per-call authentication model in particular, see . -### Design Goals - -1. **Hide complexity** without sacrificing functionality -2. **Leverage modern Swift features** (async/await, Sendable, typed throws) -3. **Maintain type safety** throughout the stack -4. **Enable testability** through protocol-oriented design -5. **Support cross-platform** development (macOS, iOS, Linux) -6. **Provide excellent ergonomics** for common operations - -### Layered Architecture +## Layered architecture ``` -┌─────────────────────────────────────────────────────────┐ -│ User Code (Application/Library Consumer) │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ MistKit Abstraction Layer │ -│ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ MistKitClient │ │ TokenManager │ │ Middleware │ │ -│ │ Configuration │ │ Hierarchy │ │ Pipeline │ │ -│ └───────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Generated OpenAPI Client (Client.swift, Types.swift) │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ OpenAPI Runtime (HTTP transport, serialization) │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ URLSession / Network Layer │ -└─────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────┐ +│ Caller (server, CLI, library consumer) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Wrapper layer │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ +│ │ CloudKitService │ │ Credentials + │ │ Authenticator│ │ +│ │ + per-call DB │ │ TokenManager │ │ family │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ +│ │ FieldValue / │ │ AuthenticationMW │ │ FilterBuilder│ │ +│ │ RecordInfo etc. │ │ + LoggingMW │ │ + QueryFilter│ │ +│ └──────────────────┘ └──────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Generated OpenAPI client (Client.swift, Types.swift) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ OpenAPIRuntime (ClientTransport, ClientMiddleware, HTTPBody) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ URLSessionTransport / custom ClientTransport (WASI, tests) │ +└─────────────────────────────────────────────────────────────────┘ ``` -## Modern Swift Concurrency Integration +Every box above either lives in `Sources/MistKit/` (hand-written) or is generated to `Sources/MistKitOpenAPI/` (committed). The generated layer never imports anything from the wrapper; the wrapper depends on the generated layer one-way. -### Async/Await Throughout +## CloudKitService: the single entry point -MistKit embraces Swift's structured concurrency with async/await patterns across the entire API surface. - -#### TokenManager Protocol +``CloudKitService`` is a small `Sendable` struct that holds three things: a container identifier, an ``Environment``, and either a ``Credentials`` value (the normal case) or a fixed `TokenManager` (tests and bespoke flows). It does **not** carry a database — every operation that supports multiple scopes takes a `database:` argument at the call site, and the right token manager is resolved per call. ```swift -/// Protocol for managing authentication tokens -public protocol TokenManager: Sendable { - /// Checks if credentials are currently available - var hasCredentials: Bool { get async } - - /// Validates the current authentication credentials - func validateCredentials() async throws(TokenManagerError) -> Bool +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public struct CloudKitService: Sendable { + public let containerIdentifier: String + public let environment: Environment - /// Retrieves the current token credentials - func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? + internal let credentials: Credentials? + internal let fixedTokenManager: (any TokenManager)? + internal let transport: any ClientTransport } ``` -**Key features:** - -- ✅ **Async properties**: `hasCredentials` is computed asynchronously -- ✅ **Typed throws**: Uses `throws(TokenManagerError)` for specific error types (Swift 6) -- ✅ **Sendable protocol**: Safe to use across actor boundaries -- ✅ **No completion handlers**: Clean, modern API surface +The four public initialisers live in `Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift`: -**Comparison with completion handler pattern:** - -```swift -// Old pattern (completion handlers) -protocol OldTokenManager { - func hasCredentials(completion: @escaping (Bool) -> Void) - func validateCredentials(completion: @escaping (Result) -> Void) - func getCurrentCredentials(completion: @escaping (Result) -> Void) -} - -// Modern pattern (async/await) -protocol TokenManager: Sendable { - var hasCredentials: Bool { get async } - func validateCredentials() async throws(TokenManagerError) -> Bool - func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? -} -``` +| Initializer | Use case | +| --- | --- | +| `init(containerIdentifier:credentials:environment:transport:)` | Standard. Per-call database, per-call token manager resolution. | +| `init(containerIdentifier:tokenManager:environment:transport:)` | Bespoke. One manager for every dispatched call regardless of database. | +| `init(containerIdentifier:credentials:environment:)` | URLSession convenience (non-WASI). | +| `init(containerIdentifier:tokenManager:environment:)` | URLSession convenience (non-WASI). | -**Benefits:** +Operations are split across focused extension files (`CloudKitService+Operations.swift`, `+WriteOperations.swift`, `+ZoneOperations.swift`, `+UserOperations.swift`, `+AssetOperations.swift`, etc.). Each extension method takes a `database:` where applicable, resolves a `TokenManager`, builds a fresh generated `Client` with that manager wired into `AuthenticationMiddleware`, and dispatches the request. -- ✅ No callback hell or nesting -- ✅ Automatic error propagation -- ✅ Task cancellation support -- ✅ Better IDE autocomplete -- ✅ Easier testing +## Authenticator: credential + signing rules -### Middleware with Async/Await - -MistKit implements the middleware pattern using OpenAPIRuntime's `ClientMiddleware` protocol: +``Authenticator`` is the protocol that owns both the credential payload and the rules for attaching it to a request: ```swift -/// Authentication middleware for CloudKit requests -internal struct AuthenticationMiddleware: ClientMiddleware { - internal let tokenManager: any TokenManager - - internal func intercept( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) - ) async throws -> (HTTPResponse, HTTPBody?) { - // Get credentials asynchronously - guard let credentials = try await tokenManager.getCurrentCredentials() else { - throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) - } - - var modifiedRequest = request - // Add authentication based on method type - switch credentials.method { - case .apiToken(let apiToken): - // Add API token to query parameters - addAPITokenAuthentication(apiToken: apiToken, to: &modifiedRequest) - - case .webAuthToken(let apiToken, let webToken): - // Add both API and web auth tokens - addWebAuthTokenAuthentication( - apiToken: apiToken, - webToken: webToken, - to: &modifiedRequest - ) - - case .serverToServer: - // Sign request with ECDSA P-256 signature - modifiedRequest = try await addServerToServerAuthentication( - to: modifiedRequest, - body: body - ) - } - - // Call next middleware in chain - return try await next(modifiedRequest, body, baseURL) - } +public protocol Authenticator: Sendable { + static var storageKey: String { get } + var defaultStorageIdentifier: String { get } + init(decoding data: Data) throws + func authenticate(request: inout HTTPRequest, body: inout HTTPBody?) async throws + func encoded() throws -> Data } ``` -**Middleware chain pattern:** - -``` -Request - ↓ -AuthenticationMiddleware.intercept() - ├─ Get credentials (async) - ├─ Modify request (add auth) - └─ next() → LoggingMiddleware.intercept() - ├─ Log request - └─ next() → Transport.send() - ↓ - Network - ↓ -Response ← ← ← ← ← ← ← ← ← ← ← -``` - -**Benefits of async middleware:** - -- ✅ Can perform async operations (fetch credentials, sign requests) -- ✅ Clean error propagation through the chain -- ✅ Composable and testable -- ✅ No blocking operations +Three concrete implementations cover the CloudKit schemes: -## Sendable Compliance and Concurrency Safety +- ``APITokenAuthenticator`` — appends `ckAPIToken=...` as a query item. +- ``WebAuthTokenAuthenticator`` — appends `ckAPIToken=...` and `ckWebAuthToken=...`. +- ``ServerToServerAuthenticator`` — buffers the body, computes an ECDSA P-256 signature, and writes the `X-Apple-CloudKit-Request-*` headers. -All types in MistKit's abstraction layer are `Sendable`, ensuring thread-safety for Swift 6's strict concurrency checking. - -### Configuration as Sendable Struct - -```swift -/// Configuration for MistKit client -internal struct MistKitConfiguration: Sendable { - internal let container: String - internal let environment: Environment - internal let database: Database - internal let apiToken: String - internal let webAuthToken: String? - internal let keyID: String? - internal let privateKeyData: Data? - internal let serverURL: URL - - // All properties are immutable (let), making the struct inherently thread-safe -} -``` +`authenticate(request:body:)` takes both `inout`. Server-to-server is the reason: it must read the request body to compute the signed payload, so it consumes the streaming body, hashes it, and re-assigns a buffered copy that downstream middleware and the transport can read again. The other two authenticators leave the body untouched. -**Why Sendable matters:** +`Authenticator` deliberately doesn't inherit `Equatable` or `Codable` — either would impose a `Self` requirement and prevent its use as `any Authenticator`, which the middleware and storage code depend on. Hand-rolled `init(decoding:)` and `encoded()` keep the on-disk format next to each type's invariants. -```swift -// Safe to use across tasks -func authenticateUser() async throws { - let config = MistKitConfiguration( - container: "iCloud.com.example", - environment: .production, - database: .private, - apiToken: ProcessInfo.processInfo.environment["API_TOKEN"]! - ) - - // Can safely pass config to another task - async let client1 = MistKitClient(configuration: config) - async let client2 = MistKitClient(configuration: config) - - // No data races - config is Sendable - let (c1, c2) = try await (client1, c2) -} -``` +## TokenManager: vending the current authenticator -### Sendable Middleware +``TokenManager`` is what `AuthenticationMiddleware` actually asks for an authenticator each request: ```swift -// Middleware structs are Sendable -internal struct AuthenticationMiddleware: ClientMiddleware { ... } -internal struct LoggingMiddleware: ClientMiddleware { ... } - -// Can be safely shared across actors -actor RequestManager { - let authMiddleware: AuthenticationMiddleware // Safe! - - func makeRequest() async throws { - // Use middleware safely within actor - } +public protocol TokenManager: Sendable { + var hasCredentials: Bool { get async } + func validateCredentials() async throws(TokenManagerError) -> Bool + func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? } ``` -## Protocol-Oriented Design - -MistKit uses protocols extensively to enable flexibility, testability, and clean architecture. +When you pass a ``Credentials`` to ``CloudKitService``, the per-call dispatcher consults ``PublicAuthPreference`` and the target ``Database`` to decide which manager to instantiate for that call. When you inject a fixed manager via the bespoke initializer, the same manager handles every call. -### TokenManager Hierarchy +A handful of concrete managers ship in the box (``APITokenManager``, ``WebAuthTokenManager``, ``ServerToServerAuthManager``, ``AdaptiveTokenManager``). Most code never names them — the ``Credentials``-driven resolution picks the right one. Implement the protocol yourself only when you need behavior the standard resolution doesn't cover (dynamic remote refresh, custom rotation). -``` - TokenManager - (protocol) - ↑ - ┌────────────────┼────────────────┐ - │ │ │ - APITokenManager WebAuthTokenManager ServerToServerAuthManager - (struct) (struct) (struct) - │ - AdaptiveTokenManager - (actor) -``` +## AuthenticationMiddleware: one place, one job -#### 1. APITokenManager +The middleware is intentionally small. It doesn't know what scheme is in use; it just asks the manager for an authenticator and lets it apply itself: ```swift -/// Manages API token authentication -public struct APITokenManager: TokenManager { - public let token: String - - public var hasCredentials: Bool { - get async { !token.isEmpty } - } - - public func validateCredentials() async throws(TokenManagerError) -> Bool { - try Self.validateAPITokenFormat(token) - return true - } - - public func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - try await validateCredentials() - return TokenCredentials(method: .apiToken(token)) +internal struct AuthenticationMiddleware: ClientMiddleware { + internal let tokenManager: any TokenManager + + internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + guard let authenticator = try await tokenManager.currentAuthenticator() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) } + var modifiedRequest = request + var modifiedBody = body + try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) + return try await next(modifiedRequest, modifiedBody, baseURL) + } } ``` -**Use case:** Container-level access, read-only operations on public database - -#### 2. WebAuthTokenManager - -```swift -/// Manages web authentication with both API and web auth tokens -public struct WebAuthTokenManager: TokenManager { - public let apiToken: String - public let webAuthToken: String - - public var hasCredentials: Bool { - get async { !apiToken.isEmpty && !webAuthToken.isEmpty } - } - - public func validateCredentials() async throws(TokenManagerError) -> Bool { - try Self.validateAPITokenFormat(apiToken) - try Self.validateWebAuthTokenFormat(webAuthToken) - return true - } +Adding a new authentication scheme means adding a new ``Authenticator`` and (if needed) a new manager. The middleware does not change. This is the structural payoff from making the credential type carry its own signing rules. - public func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - try await validateCredentials() - return TokenCredentials(method: .webAuthToken(apiToken, webAuthToken)) - } -} ``` - -**Use case:** User-specific operations, private/shared database access - -#### 3. ServerToServerAuthManager - -```swift -/// Manages server-to-server authentication using ECDSA P-256 signatures -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct ServerToServerAuthManager: TokenManager { - public let keyIdentifier: String - public let privateKeyData: Data - private let privateKey: P256.Signing.PrivateKey - - public init(keyID: String, privateKeyData: Data) throws { - self.keyIdentifier = keyID - self.privateKeyData = privateKeyData - self.privateKey = try P256.Signing.PrivateKey(rawRepresentation: privateKeyData) - } - - public func signRequest( - requestBody: Data?, - webServiceURL: String - ) throws -> RequestSignature { - let currentDate = Date() - let iso8601Date = ISO8601DateFormatter().string(from: currentDate) - - // Create signature payload - let payload = "\(iso8601Date):\(requestBody?.base64EncodedString() ?? ""):\(webServiceURL)" - let payloadData = Data(payload.utf8) - - // Sign with ECDSA P-256 - let signature = try privateKey.signature(for: SHA256.hash(data: payloadData)) - let signatureBase64 = signature.rawRepresentation.base64EncodedString() - - return RequestSignature( - keyID: keyIdentifier, - date: iso8601Date, - signature: signatureBase64 - ) - } -} +Request + │ + ▼ +AuthenticationMiddleware.intercept(request, body) + ├── tokenManager.currentAuthenticator() (async) + ├── authenticator.authenticate(&request, &body) (sign / append query items) + └── next(modifiedRequest, modifiedBody, baseURL) + │ + ▼ + LoggingMiddleware (debug builds) + │ + ▼ + ClientTransport (URLSession) + │ + ▼ + api.apple-cloudkit.com ``` -**Use case:** Enterprise/server applications, public database only, no user context +## Sendable everywhere -### Benefits of Protocol-Oriented Design +Every type that crosses a task boundary is `Sendable`. The wrapper enforces this top-down: -**1. Easy testing with mocks:** +- ``CloudKitService`` is a `Sendable` struct with `let` fields. +- ``Credentials`` and the credential structs are `Sendable` value types. +- ``Authenticator`` declares a `Sendable` constraint on the protocol itself. +- ``TokenManager`` likewise. -```swift -struct MockTokenManager: TokenManager { - var mockCredentials: TokenCredentials? - var shouldThrow: Bool = false +Token-manager *implementations* that need mutable state (``AdaptiveTokenManager``, anything that caches a refreshed token) are `actor`s — the only `Sendable` shape that owns mutable state safely under Swift 6 strict concurrency. The middleware never reaches into those actors directly; it only calls `currentAuthenticator()`, which is `async`. - var hasCredentials: Bool { - get async { mockCredentials != nil } - } - - func validateCredentials() async throws(TokenManagerError) -> Bool { - if shouldThrow { - throw TokenManagerError.invalidCredentials(.apiTokenEmpty) - } - return mockCredentials != nil - } - - func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - mockCredentials - } -} +## Typed throws -// Use in tests -let mockManager = MockTokenManager(mockCredentials: .apiToken("test-token")) -let client = try MistKitClient( - configuration: testConfig, - tokenManager: mockManager, - transport: mockTransport -) -``` - -**2. Flexible implementation swapping:** +Authentication code uses typed throws: ```swift -// Development: Use API token -let devTokenManager = APITokenManager(token: devAPIToken) - -// Production: Use server-to-server auth -let prodTokenManager = try ServerToServerAuthManager( - keyID: prodKeyID, - privateKeyData: prodPrivateKey -) - -// Same client code works with either -let client = try MistKitClient( - configuration: config, - tokenManager: prodTokenManager // or devTokenManager -) +public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? ``` -**3. Protocol extensions for shared logic:** - -```swift -extension TokenManager { - /// Shared validation logic for all token managers - internal static func validateAPITokenFormat(_ apiToken: String) throws(TokenManagerError) { - guard !apiToken.isEmpty else { - throw TokenManagerError.invalidCredentials(.apiTokenEmpty) - } - - let regex = NSRegularExpression.apiTokenRegex - let matches = regex.matches(in: apiToken) - - guard !matches.isEmpty else { - throw TokenManagerError.invalidCredentials(.apiTokenInvalidFormat) - } - } -} -``` +Callers know they're catching ``TokenManagerError`` specifically and can switch on ``InvalidCredentialReason`` / ``AuthenticationFailedReason`` / ``InternalErrorReason`` / ``NetworkErrorReason`` without `as?` casts. CloudKit operation errors map to ``CloudKitError`` — see for how generated response enums are folded into that type. -## Dependency Injection Pattern +## FieldValue: request and response are different shapes -MistKit uses constructor injection to promote testability and flexibility. +The CloudKit API is asymmetric: a field value in a request body omits the `type` field (CloudKit infers it from the value), while a field value in a response sometimes includes `type` explicitly. Reflecting this in the OpenAPI schema gives two generated types: -### MistKitClient Initialization +- `Components.Schemas.FieldValueRequest` — used inside `RecordRequest`. +- `Components.Schemas.FieldValueResponse` — used inside `RecordResponse`. -```swift -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -internal struct MistKitClient { - internal let client: Client - - /// Initialize with explicit dependencies - internal init( - configuration: MistKitConfiguration, - tokenManager: any TokenManager, - transport: any ClientTransport - ) throws { - // Validate configuration - try Self.validateServerToServerConfiguration( - configuration: configuration, - tokenManager: tokenManager - ) - - // Create client with injected dependencies - self.client = Client( - serverURL: configuration.serverURL, - transport: transport, - middlewares: [ - AuthenticationMiddleware(tokenManager: tokenManager), - LoggingMiddleware() - ] - ) - } +The wrapper exposes a single domain type, ``FieldValue``, and converts both directions: - /// Convenience initializer with defaults - internal init(configuration: MistKitConfiguration) throws { - let tokenManager = try configuration.createTokenManager() - try self.init( - configuration: configuration, - tokenManager: tokenManager, - transport: URLSessionTransport() // Default transport - ) - } -} -``` +- `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` — domain ``FieldValue`` → `FieldValueRequest`. +- `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` — `FieldValueResponse` → domain ``FieldValue``. -**Benefits:** +Splitting the generated types means the compiler refuses to put a response value in a request slot. The single domain enum gives callers an ergonomic API. -1. **Testability**: Inject mock transport and token managers -2. **Flexibility**: Swap implementations without changing client code -3. **Clear dependencies**: Explicit about what the client needs -4. **Defaults available**: Convenience initializers for common cases +## Query construction: QueryFilter and QuerySort -### Testing with Dependency Injection +``QueryFilter`` and ``QuerySort`` are the public, typed surface for building queries. Each is a `struct` with static factory methods that mirror the CloudKit comparators: ```swift -// Production code -let client = try MistKitClient(configuration: prodConfig) - -// Test code - inject mocks -let mockTransport = MockTransport(cannedResponse: mockQueryResponse) -let mockTokenManager = MockTokenManager(mockCredentials: testCredentials) - -let testClient = try MistKitClient( - configuration: testConfig, - tokenManager: mockTokenManager, - transport: mockTransport +let result = try await service.queryRecords( + recordType: "Note", + filters: [ + .greaterThan("modifiedAt", .date(since)), + .listContains("tags", .string("important")), + ], + sortBy: [.descending("modifiedAt")], + database: .private ) - -// Test without hitting real network -let response = try await testClient.queryRecords(...) ``` -## Custom Type Mapping: CustomFieldValue +The internal `FilterBuilder` (`Helpers/FilterBuilder.swift` + extensions) emits the underlying `Components.Schemas.Filter` values. List comparators wrap values in `ListValuePayload` so the JSON shape matches what CloudKit expects. -MistKit overrides the generated `FieldValue` type with a custom implementation that provides better handling of CloudKit field types. - -### Type Override Configuration - -```yaml -# openapi-generator-config.yaml -typeOverrides: - schemas: - FieldValue: CustomFieldValue -``` +## Pagination -### Implementation +Query responses carry a continuation marker. ``QueryResult`` exposes it: ```swift -/// Custom implementation of FieldValue with proper ASSETID handling -internal struct CustomFieldValue: Codable, Hashable, Sendable { - /// Field type payload for CloudKit fields - public enum FieldTypePayload: String, Codable, Hashable, Sendable, CaseIterable { - case string = "STRING" - case int64 = "INT64" - case double = "DOUBLE" - case bytes = "BYTES" - case reference = "REFERENCE" - case asset = "ASSET" - case assetid = "ASSETID" // Special handling for asset IDs - case location = "LOCATION" - case timestamp = "TIMESTAMP" - case list = "LIST" - } - - /// Custom field value payload supporting various CloudKit types - public enum CustomFieldValuePayload: Codable, Hashable, Sendable { - case stringValue(String) - case int64Value(Int) - case doubleValue(Double) - case booleanValue(Bool) - case bytesValue(String) - case dateValue(Double) - case locationValue(Components.Schemas.LocationValue) - case referenceValue(Components.Schemas.ReferenceValue) - case assetValue(Components.Schemas.AssetValue) - case listValue([CustomFieldValuePayload]) - } - - internal let value: CustomFieldValuePayload - internal let type: FieldTypePayload? - - // Custom Codable implementation with type-specific decoders - private static let fieldTypeDecoders: - [FieldTypePayload: @Sendable (KeyedDecodingContainer) throws - -> CustomFieldValuePayload] = [ - .string: { .stringValue(try $0.decode(String.self, forKey: .value)) }, - .int64: { .int64Value(try $0.decode(Int.self, forKey: .value)) }, - .asset: { .assetValue(try $0.decode(Components.Schemas.AssetValue.self, forKey: .value)) }, - .assetid: { .assetValue(try $0.decode(Components.Schemas.AssetValue.self, forKey: .value)) }, - // ... more decoders - ] +public struct QueryResult: Codable, Sendable { + public let records: [RecordInfo] + public let continuationMarker: String? } ``` -**Why custom implementation?** +Two iteration helpers cover the common cases: -1. **CloudKit-specific handling**: ASSETID type requires special treatment -2. **Better ergonomics**: Enum-based value access instead of dictionaries -3. **Type safety**: Compile-time checking for field value types -4. **Proper encoding**: Handles CloudKit's JSON format correctly +- ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` — single page. +- `queryAllRecords(...)` — auto-pagination with an enforced maximum, surfacing ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the already-fetched records when the cap is reached. -### Usage Comparison +Sync endpoints follow the same shape: ``RecordChangesResult`` and ``ZoneChangesResult`` carry `syncToken` and `moreComing`. `fetchAllRecordChanges(recordType:syncToken:)` walks the cursor automatically. -**Before (generated FieldValue):** +## Asset upload: separate URLSession by design -```swift -// Hypothetical generated code (generic, not CloudKit-specific) -let fieldValue = FieldValue(value: ["someKey": someValue]) -// Type: Any? - no compile-time safety -``` - -**After (CustomFieldValue):** - -```swift -// Type-safe, CloudKit-aware -let fieldValue = CustomFieldValue( - value: .stringValue("John Doe"), - type: .string -) - -// Pattern matching for safe access -switch fieldValue.value { -case .stringValue(let name): - print("Name: \(name)") -case .int64Value(let age): - print("Age: \(age)") -case .assetValue(let asset): - print("Asset URL: \(asset.downloadURL)") -default: - break -} -``` - -## Error Handling with Typed Throws - -MistKit leverages Swift 6's typed throws for precise error handling. - -### TokenManagerError - -```swift -/// Errors that can occur during token management -public enum TokenManagerError: Error, Sendable { - case invalidCredentials(InvalidCredentialReason) - case internalError(InternalErrorReason) -} - -/// Specific reasons for invalid credentials -public enum InvalidCredentialReason: Sendable { - case apiTokenEmpty - case apiTokenInvalidFormat - case webAuthTokenEmpty - case webAuthTokenTooShort - case noCredentialsAvailable - case serverToServerOnlySupportsPublicDatabase(String) -} -``` - -### Usage with Typed Throws - -```swift -// Function signature with typed throws -func validateCredentials() async throws(TokenManagerError) -> Bool - -// Caller knows exactly what error type to expect -do { - let isValid = try await tokenManager.validateCredentials() -} catch let error as TokenManagerError { - // Can switch on specific error cases - switch error { - case .invalidCredentials(.apiTokenEmpty): - print("API token is empty") - case .invalidCredentials(.apiTokenInvalidFormat): - print("API token format is invalid") - case .internalError(let reason): - print("Internal error: \(reason)") - } -} -``` - -**Comparison with untyped throws:** - -```swift -// Untyped throws - unclear what errors can occur -func validateCredentials() async throws -> Bool - -// Typed throws - explicit error type -func validateCredentials() async throws(TokenManagerError) -> Bool -``` - -## Security and Logging - -### Secure Logging - -MistKit implements secure logging that automatically masks sensitive information: - -```swift -internal enum SecureLogging { - /// Masks tokens and sensitive data in log messages - internal static func maskToken(_ token: String) -> String { - guard token.count > 8 else { - return "***" - } - let prefix = token.prefix(4) - let suffix = token.suffix(4) - return "\(prefix)***\(suffix)" - } - - /// Safely log messages with automatic token masking - internal static func safeLogMessage(_ message: String) -> String { - var safe = message - - // Mask API tokens - safe = safe.replacingOccurrences( - of: #"ckAPIToken=[^&\s]+"#, - with: "ckAPIToken=***", - options: .regularExpression - ) - - // Mask web auth tokens - safe = safe.replacingOccurrences( - of: #"ckWebAuthToken=[^&\s]+"#, - with: "ckWebAuthToken=***", - options: .regularExpression - ) - - return safe - } -} -``` - -### LoggingMiddleware with Security - -```swift -internal struct LoggingMiddleware: ClientMiddleware { - #if DEBUG - private func logRequest(_ request: HTTPRequest, baseURL: URL) { - print("🌐 CloudKit Request: \(request.method.rawValue) \(fullPath)") - - // Log query parameters with masking - for item in queryItems { - let value = formatQueryValue(for: item) // Masks sensitive values - print(" \(item.name): \(value)") - } - } - - private func formatQueryValue(for item: URLQueryItem) -> String { - guard let value = item.value else { return "nil" } - - // Mask sensitive query parameters - if item.name.lowercased().contains("token") || - item.name.lowercased().contains("key") { - return SecureLogging.maskToken(value) - } - - return value - } - #endif -} -``` - -**Output example:** - -``` -🌐 CloudKit Request: POST https://api.apple-cloudkit.com/database/1/iCloud.com.example/production/private/records/query - ckAPIToken: c34a***7d9f - ckWebAuthToken: 9f2e***4b1a -``` - -## Future Architecture Enhancements - -While MistKit's current architecture is robust, several modern Swift features could further enhance the abstraction layer: - -### Potential: Actor-Based Token Management - -```swift -// Future: Actor for thread-safe token caching -actor TokenCacheManager: TokenManager { - private var cachedCredentials: TokenCredentials? - private var lastValidation: Date? - private let validationInterval: TimeInterval = 300 // 5 minutes - - func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - // Check cache - if let cached = cachedCredentials, - let lastValidation = lastValidation, - Date().timeIntervalSince(lastValidation) < validationInterval { - return cached - } - - // Fetch and cache new credentials - let credentials = try await fetchCredentials() - self.cachedCredentials = credentials - self.lastValidation = Date() - return credentials - } -} -``` - -**Benefits:** -- ✅ Thread-safe token caching -- ✅ Automatic invalidation -- ✅ No data races - -### Potential: AsyncSequence for Pagination - -```swift -// Future: AsyncSequence for paginated queries -struct RecordQuerySequence: AsyncSequence { - typealias Element = CloudKitRecord - - let query: RecordQuery - let client: MistKitClient - - func makeAsyncIterator() -> Iterator { - Iterator(query: query, client: client) - } - - struct Iterator: AsyncIteratorProtocol { - var continuationMarker: String? - let query: RecordQuery - let client: MistKitClient - - mutating func next() async throws -> CloudKitRecord? { - let response = try await client.queryRecords( - query: query, - continuationMarker: continuationMarker - ) - - continuationMarker = response.continuationMarker - - return response.records.first - } - } -} - -// Usage -for try await record in client.queryRecords(type: "User") { - print(record.name) - // Automatically fetches next page when needed -} -``` - -### Potential: Result Builders for Query Construction - -```swift -// Future: Result builder for declarative queries -@resultBuilder -enum QueryBuilder { - static func buildBlock(_ components: QueryFilter...) -> [QueryFilter] { - components - } -} - -func query(@QueryBuilder _ filters: () -> [QueryFilter]) -> RecordQuery { - RecordQuery(filters: filters()) -} - -// Usage -let userQuery = query { - Filter(field: "age", comparator: .greaterThan, value: 18) - Filter(field: "status", comparator: .equals, value: "active") - Sort(field: "lastName", ascending: true) -} - -// vs. current approach -let userQuery = RecordQuery( - filters: [ - Filter(field: "age", comparator: .greaterThan, value: 18), - Filter(field: "status", comparator: .equals, value: "active") - ], - sorts: [ - Sort(field: "lastName", ascending: true) - ] -) -``` - -### Potential: Property Wrappers for Field Mapping - -```swift -// Future: Property wrappers for model mapping -@propertyWrapper -struct CloudKitField { - let key: String - var wrappedValue: Value - - init(wrappedValue: Value, _ key: String) { - self.key = key - self.wrappedValue = wrappedValue - } -} - -struct User { - @CloudKitField("firstName") var firstName: String - @CloudKitField("lastName") var lastName: String - @CloudKitField("age") var age: Int - @CloudKitField("email") var email: String - - // Automatic mapping to/from CloudKit records -} - -// vs. current approach (manual field mapping) -let record = CloudKitRecord( - fields: [ - "firstName": .stringValue(user.firstName), - "lastName": .stringValue(user.lastName), - "age": .int64Value(user.age), - "email": .stringValue(user.email) - ] -) -``` +Asset upload is a two-step dance: ask CloudKit for a CDN URL, then PUT the bytes to the CDN. The two steps target **different hosts** (`api.apple-cloudkit.com` and `cvws.icloud-content.com`). -## Summary +URLSession (and any HTTP/2 client) will happily reuse a connection between hosts when it can, and CloudKit's CDN responds with `421 Misdirected Request` if the wrong host is reached over a reused HTTP/2 connection. To avoid that, asset upload uses `URLSession.shared.upload(_:to:)` directly via a dedicated ``AssetUploader`` closure — **not** the injected `ClientTransport`. The two connection pools stay separate. -MistKit's abstraction layer provides: +The closure shape (`(Data, URL) async throws -> (statusCode: Int?, data: Data)`) is a dependency-injection seam: tests pass in a stub uploader without touching the network. Custom uploaders in production code must preserve the connection-pool separation, or the same 421 errors will return. -### Current Implementation +## Logging -- ✅ **Async/await integration** throughout the API -- ✅ **Sendable compliance** for Swift 6 concurrency safety -- ✅ **Protocol-oriented design** enabling flexibility and testability -- ✅ **Dependency injection** for loose coupling -- ✅ **Middleware pattern** for cross-cutting concerns -- ✅ **Custom type mapping** for CloudKit-specific needs -- ✅ **Typed throws** for precise error handling -- ✅ **Secure logging** with automatic credential masking +`MistKitLogger` is the central swift-log wrapper with three subsystems (`api`, `auth`, `network`). Helpers (`logError`, `logWarning`, `logInfo`, `logDebug`) call through `SecureLogging.safeLogMessage` by default to mask tokens, key IDs, and other secrets. Set `MISTKIT_DISABLE_LOG_REDACTION=1` to suppress redaction while debugging. -### Architectural Benefits +`LoggingMiddleware` runs after `AuthenticationMiddleware` and emits structured request/response logs in `DEBUG` builds — the auth values it sees are already in their wire form, but the secure helpers redact them again before they reach the log line. -- 🎯 **Type safety** from the generated code through to the API surface -- 🎯 **Testability** through protocol abstractions and dependency injection -- 🎯 **Maintainability** with clear separation of concerns -- 🎯 **Ergonomics** hiding complexity without losing functionality -- 🎯 **Cross-platform** support (macOS, iOS, tvOS, watchOS, Linux) -- 🎯 **Future-proof** leveraging latest Swift features +## What the wrapper does *not* do -### Future Enhancements +A few intentional non-features that show up in many wrapper libraries but not this one: -- 🔮 Actor-based token caching for improved concurrency -- 🔮 AsyncSequence for elegant pagination -- 🔮 Result builders for declarative query construction -- 🔮 Property wrappers for simplified model mapping +- **No `await` on every property.** ``CloudKitService`` is a `Sendable` struct, not an actor. Async surface is restricted to actual I/O. +- **No global state.** No shared client, no singletons, no ambient credentials. Every test gets its own service. +- **No completion-handler overloads.** Single async surface everywhere. +- **No record model registry.** ``RecordInfo`` is a typed dictionary on purpose — record schemas live in CloudKit, not in Swift type definitions. Build your own domain types on top. ## See Also +- - - - - [Swift Concurrency Documentation](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html) -- [Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/) +- [swift-openapi-runtime](https://github.com/apple/swift-openapi-runtime) diff --git a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md new file mode 100644 index 00000000..13ab1018 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md @@ -0,0 +1,216 @@ +# Authentication and Databases + +Configure ``CloudKitService`` once with the credentials it needs, then pick a ``Database`` — and, for `.public`, a signing method — at every call site. + +## Overview + +CloudKit Web Services accepts three authentication schemes, and only some scheme/database combinations are legal: + +| Database | API token | Web auth | Server-to-server | +| --- | :-: | :-: | :-: | +| `.public` | read-only | ✓ user-attributed | ✓ developer-attributed | +| `.private` | — | ✓ | — | +| `.shared` | — | ✓ | — | + +The same backend legitimately needs both attribution paths — server-attributed writes against the public database (catalog seeds, moderation actions) and user-attributed reads against `users/caller` (knowing which iCloud user a session belongs to). MistKit models this by: + +1. Letting ``CloudKitService`` hold a ``Credentials`` value that carries either or both credential sets. +2. Making the target ``Database`` an argument on each operation, with `.public` carrying a ``PublicAuthPreference`` that picks the signing method *for that call*. + +Configuration is what's available; the call site picks what to use. + +## Construct credentials + +``Credentials`` holds an optional ``APICredentials`` and/or ``ServerToServerCredentials``. At least one must be present — an empty value asserts in debug and throws ``CredentialsValidationError/empty`` in release. + +### API token (with optional web-auth token) + +The API token alone gives container-level access to the public database. Add a web-auth token to operate as a specific iCloud user — required for `.private` and `.shared`, and for any user-identity route. + +```swift +let credentials = try Credentials( + apiAuth: APICredentials( + apiToken: env("CLOUDKIT_API_TOKEN"), + webAuthToken: env("CLOUDKIT_WEB_AUTH_TOKEN") // optional + ) +) +``` + +### Server-to-server (developer-attributed) + +Provide a CloudKit key ID and an ECDSA P-256 private key. ``PrivateKeyMaterial`` accepts either raw key bytes, PEM data, or a path to a PEM file. + +```swift +let credentials = try Credentials( + serverToServer: ServerToServerCredentials( + keyID: env("CLOUDKIT_KEY_ID"), + privateKey: .file(path: env("CLOUDKIT_PRIVATE_KEY_PATH")) + ) +) +``` + +``PrivateKeyMaterial`` is `.raw(String)` for an inline PEM (literal `\n` escapes are normalized) or `.file(path:)` for a PEM read off disk when the credentials are first consumed. + +### Both — one service, both attribution paths + +Populate both fields when a single backend has work that splits across attribution boundaries: + +```swift +let credentials = try Credentials( + serverToServer: ServerToServerCredentials( + keyID: env("CLOUDKIT_KEY_ID"), + privateKey: .file(path: env("CLOUDKIT_PRIVATE_KEY_PATH")) + ), + apiAuth: APICredentials( + apiToken: env("CLOUDKIT_API_TOKEN"), + webAuthToken: env("CLOUDKIT_WEB_AUTH_TOKEN") + ) +) +``` + +## Build the service + +```swift +let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + credentials: credentials, + environment: .production +) +``` + +The service does **not** carry a database. The database is chosen per call, and the appropriate token manager is resolved from ``Credentials`` each time. Misconfiguration (no credential set covers a given call's database/user-context combination) surfaces at the call site as ``CloudKitError/missingCredentials(database:availability:reason:)``, not at construction. + +For a custom transport (mock, instrumented, WASI), use the generic initializer: + +```swift +let service = CloudKitService( + containerIdentifier: container, + credentials: credentials, + environment: .production, + transport: customTransport +) +``` + +## Pick a database per call + +``Database`` is an enum with three cases: + +```swift +public enum Database: Sendable, Hashable { + case `public`(PublicAuthPreference) + case `private` + case shared +} +``` + +`.private` and `.shared` carry no payload — they always sign with web-auth (the only scheme CloudKit accepts on those scopes). + +```swift +let notes = try await service.queryRecords( + recordType: "Note", + database: .private +) +``` + +`.public` requires a ``PublicAuthPreference`` so each call says explicitly how it wants to be attributed: + +```swift +// Server-attributed: catalog seed write that should look like "the app did this". +try await service.createRecord( + recordType: "FeaturedPost", + fields: featuredPostFields, + database: .public(.requires(.serverToServer)) +) + +// User-attributed: a public post created by the signed-in user. +try await service.createRecord( + recordType: "Post", + fields: postFields, + database: .public(.requires(.webAuth)) +) +``` + +## Two preference modes: prefers vs requires + +Both factories take a ``PublicAuthPreference/Mode`` (``PublicAuthPreference/Mode/serverToServer`` or ``PublicAuthPreference/Mode/webAuth``): + +| Factory | Behavior when the chosen scheme is missing | +| --- | --- | +| ``PublicAuthPreference/prefers(_:)`` | Fall back to the other configured credential set when possible. | +| ``PublicAuthPreference/requires(_:)`` | Throw ``CloudKitError/missingCredentials(database:availability:reason:)`` with `availability == .preferenceRequired`. | + +Use `.prefers(_:)` when either attribution is acceptable and you'd rather degrade gracefully than fail (development tooling, mixed environments). Use `.requires(_:)` when attribution is part of the contract — a write that *must* be attributed to a specific user, or a server task that *must not* leak user identity — and a misconfigured deployment should fail loudly. + +There is no default on the `database:` parameter. Every call picks explicitly. + +## User-identity routes + +A handful of routes (`/users/caller`, `/users/discover`, `/users/lookup/email`, `/users/lookup/id`) only work against the public database with web-auth credentials — CloudKit rejects server-to-server signing on these endpoints. MistKit's user-identity methods (``CloudKitService/fetchCaller()``, ``CloudKitService/lookupUsersByEmail(_:)``, ``CloudKitService/lookupUsersByRecordName(_:)``) pass `.public(.requires(.webAuth))` internally — they will throw ``CloudKitError/missingCredentials(database:availability:reason:)`` if your ``Credentials`` lack ``APICredentials/webAuthToken``. + +## Where the signing happens + +The middleware chain is one step: ``Authenticator`` does the work, the middleware just hands it the request. + +``` +service.createRecord(database: .public(.requires(.webAuth))) + │ + ▼ + TokenManager.currentAuthenticator() ← picked from Credentials + │ + ▼ + AuthenticationMiddleware.intercept(request) + │ ← appends ckAPIToken=, + Authenticator.authenticate(request:body:) ← ckWebAuthToken=, or + │ ← X-Apple-CloudKit-* headers + ▼ + next(request, body, baseURL) +``` + +For server-to-server, ``ServerToServerAuthenticator`` consumes the request body to compute the signed payload, then reassigns a buffered copy so downstream middleware and the transport see the same bytes. + +## When to use a custom TokenManager + +The standard path — ``Credentials`` plus per-call ``Database`` — covers almost every use. Reach for ``CloudKitService/init(containerIdentifier:tokenManager:environment:transport:)`` only when: + +- You need to **dynamically refresh** credentials between requests (e.g. rotate web-auth tokens from a remote secret store). +- You're **testing** and want every dispatched operation to use a stub manager that returns canned authenticators. +- You're building a **specialized auth flow** that doesn't fit the developer-key / user-token / API-token taxonomy. + +A custom manager is used for *every* dispatched operation regardless of database — you opt out of the per-call resolution entirely. See ``TokenManager`` and the concrete managers under "Advanced — custom token managers and storage" on the module landing page. + +## Reference material + +The longer prose guides live in the repo (outside this DocC bundle): + +- `docs/cloudkit-guide/articles/authenticating-cloudkit-backend-services.md` — full backend setup walkthrough including obtaining tokens, the browser-redirect web-auth flow, `CKFetchWebAuthTokenOperation` for iOS handoff, CloudKit Dashboard configuration, and CI/CD secret rotation. +- `docs/internals/authentication-middleware.md` — Mermaid diagrams of the middleware chain and per-scheme signing paths. + +## Topics + +### Credentials + +- ``Credentials`` +- ``APICredentials`` +- ``ServerToServerCredentials`` +- ``PrivateKeyMaterial`` +- ``CredentialsValidationError`` + +### Database scoping + +- ``Database`` +- ``PublicAuthPreference`` +- ``PublicAuthPreference/Mode`` + +### Request signing + +- ``Authenticator`` +- ``APITokenAuthenticator`` +- ``WebAuthTokenAuthenticator`` +- ``ServerToServerAuthenticator`` + +### Errors + +- ``CloudKitError`` +- ``CredentialAvailability`` +- ``TokenManagerError`` +- ``InvalidCredentialReason`` diff --git a/Sources/MistKit/Documentation.docc/Documentation.md b/Sources/MistKit/Documentation.docc/Documentation.md index 5b7602ce..e2601ee6 100644 --- a/Sources/MistKit/Documentation.docc/Documentation.md +++ b/Sources/MistKit/Documentation.docc/Documentation.md @@ -1,189 +1,151 @@ # ``MistKit`` -A Swift Package for Server-Side and Command-Line Access to CloudKit Web Services +A Swift package for server-side and command-line access to CloudKit Web Services. ![MistKit Logo](logo) ## Overview -MistKit provides a modern Swift interface to CloudKit Web Services REST API, enabling cross-platform CloudKit access for server-side Swift applications, command-line tools, and platforms where the CloudKit framework isn't available. +MistKit wraps Apple's [CloudKit Web Services REST API](https://developer.apple.com/documentation/cloudkitwebservices) with a modern Swift surface so server-side code, CLIs, and platforms without the native CloudKit framework (Linux, WASI, Windows) can read and write the same containers as your Apple apps. -Built with Swift concurrency (async/await) and designed for modern Swift applications, MistKit supports all three CloudKit authentication methods and provides type-safe access to CloudKit operations. +The library is built on `swift-openapi-generator` against Apple's published OpenAPI specification, with a hand-written abstraction layer on top that exposes typed records, async iteration, structured errors, and three authentication schemes. -## Key Features +## Quick start -- **Cross-Platform Support**: Works on macOS, iOS, tvOS, watchOS, visionOS, and Linux -- **Modern Swift**: Built with Swift 6 concurrency features and structured error handling -- **Multiple Authentication Methods**: API token, web authentication, and server-to-server authentication -- **Type-Safe**: Comprehensive type safety with Swift's type system -- **OpenAPI-Based**: Generated from CloudKit Web Services OpenAPI specification -- **Secure**: Built-in security best practices and credential management - -## Authentication Methods - -### API Token Authentication - -Provides container-level access using an API token from Apple Developer Console: +Construct a ``CloudKitService`` with a ``Credentials`` value and pick a ``Database`` at each call site: ```swift -let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.MyApp", - apiToken: "your-api-token" -) -``` - -### Web Authentication - -Enables user-specific operations with both API token and web authentication token: +import MistKit -```swift -let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.MyApp", - apiToken: "your-api-token", - webAuthToken: "user-web-auth-token" +let credentials = try Credentials( + apiAuth: APICredentials( + apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]!, + webAuthToken: ProcessInfo.processInfo.environment["CLOUDKIT_WEB_AUTH_TOKEN"] + ) ) -``` - -### Server-to-Server Authentication - -Enterprise-level authentication using ECDSA P-256 key signing (public database only): -```swift -let serverManager = try ServerToServerAuthManager( - keyIdentifier: "your-key-id", - privateKeyData: privateKeyData +let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + credentials: credentials, + environment: .production ) -let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.MyApp", - tokenManager: serverManager, - environment: .production, - database: .public +let result = try await service.queryRecords( + recordType: "Note", + database: .private ) ``` -## Getting Started +`.private` and `.shared` always sign with the web-auth token. `.public` carries a ``PublicAuthPreference`` — either ``PublicAuthPreference/prefers(_:)`` or ``PublicAuthPreference/requires(_:)`` — so each public-database call decides whether it is attributed to the developer key (server-to-server) or to the iCloud user (web-auth). -### Installation +For the full set-up walkthrough — obtaining tokens, generating an ECDSA P-256 key, and running the service on a backend — see . -Add MistKit to your project using Swift Package Manager: +## Architecture at a glance -```swift -dependencies: [ - .package(url: "https://github.com/your-org/MistKit.git", from: "1.0.0") -] ``` - -### Basic Usage - -1. **Choose Authentication**: Select your authentication method based on your needs -2. **Create Service**: Initialize CloudKitService with your authentication details -3. **Perform Operations**: Use the service to interact with CloudKit Web Services - -```swift -import MistKit - -// Create service with API token authentication -let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.MyApp", - apiToken: ProcessInfo.processInfo.environment["CLOUDKIT_API_TOKEN"]! -) - -// Use the service for CloudKit operations -// (Specific operations depend on your use case) +Your code + │ + ▼ +CloudKitService ← per-call Database; resolves a TokenManager + │ from Credentials + ▼ +AuthenticationMiddleware ← asks the current Authenticator to sign/attach + │ + ▼ +Generated OpenAPI Client ← produced by swift-openapi-generator + │ + ▼ +ClientTransport ← URLSessionTransport by default + │ + ▼ +api.apple-cloudkit.com ``` -## Error Handling - -MistKit provides comprehensive error handling with typed errors: - -- ``CloudKitError`` - CloudKit Web Services API errors -- ``TokenManagerError`` - Authentication and credential errors -- ``TokenStorageError`` - Token storage and persistence errors +The wrapper layer is described in . The code-generation pipeline that produces the OpenAPI client is covered in and . -All errors conform to `LocalizedError` for user-friendly error messages. +## Platform support -## Security Best Practices +MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. Server-to-server signing depends on Crypto / swift-crypto, so it is unavailable on Windows and WASI — those targets must use API-token + web-auth credentials. URL-loading conveniences and asset upload use `URLSession`; on WASI builds you supply a `ClientTransport` explicitly via the generic initializer. -- **Environment Variables**: Store sensitive credentials in environment variables -- **Token Rotation**: Implement proper token rotation for server-to-server authentication -- **Secure Storage**: Use secure storage mechanisms for persistent credentials -- **Logging**: Sensitive information is automatically masked in logs +> Tip: On native Apple platforms (macOS, iOS, tvOS, watchOS, visionOS) prefer the native [CloudKit framework](https://developer.apple.com/documentation/cloudkit). It integrates with the system account, handles push notifications and long-lived operations, and avoids the per-request signing overhead of the web-services API. MistKit is intended for environments where the native framework isn't available — server-side Swift, CLIs, Linux, and Windows. -## Platform Support - -### Minimum Platform Versions - -- macOS 10.15+ -- iOS 13.0+ -- tvOS 13.0+ -- watchOS 6.0+ -- visionOS 1.0+ -- Linux (Ubuntu 18.04+) - -### Server-to-Server Authentication - -Server-to-server authentication requires Crypto framework support: -- macOS 11.0+ -- iOS 14.0+ -- tvOS 14.0+ -- watchOS 7.0+ -- Linux with swift-crypto +> Warning: WASI is not a fully supported target. The web-services API requires HMAC/ECDSA signing and a working HTTP transport, neither of which has a first-class story on WASI today. For CloudKit access from the browser, use Apple's official [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs) library instead. ## Topics -### Architecture and Development +### Getting Started -- -- -- - - -### Services - - ``CloudKitService`` -- ``RequestSignature`` +- ``Database`` +- ``Environment`` +- ``CloudKitError`` +- ``RecordInfo`` +- ``FieldValue`` +- ``QueryFilter`` +- ``QuerySort`` +- ``QueryResult`` +- ``RecordOperation`` +- ``RecordChangesResult`` +- ``RecordTimestamp`` +- ``ZoneID`` +- ``ZoneInfo`` +- ``ZoneOperation`` +- ``ZoneChangesResult`` +- ``UserInfo`` +- ``UserIdentity`` +- ``UserIdentityLookupInfo`` +- ``NameComponents`` +- ``OperationClassification`` +- ``BatchSyncResult`` +- ``AssetUploadResponse`` +- ``AssetUploadReceipt`` +- ``AssetUploadToken`` +- ``AssetUploader`` ### Authentication +- +- ``Credentials`` +- ``APICredentials`` +- ``ServerToServerCredentials`` +- ``PublicAuthPreference`` +- ``PrivateKeyMaterial`` +- ``Authenticator`` +- ``APITokenAuthenticator`` +- ``WebAuthTokenAuthenticator`` +- ``ServerToServerAuthenticator`` - ``TokenManager`` - ``APITokenManager`` - ``WebAuthTokenManager`` - ``AdaptiveTokenManager`` - ``ServerToServerAuthManager`` -- ``TokenCredentials`` -- ``AuthenticationMethod`` -- ``AuthenticationMode`` - -### Storage - - ``TokenStorage`` -- ``InMemoryTokenStorage`` -- ``TokenStorageError`` - -### Configuration - -- ``Environment`` -- ``Database`` -- ``EnvironmentConfig`` - -### Errors - -- ``CloudKitError`` -- ``TokenManagerError`` +- ``CredentialsValidationError`` +- ``CredentialAvailability`` - ``InvalidCredentialReason`` +- ``AuthenticationFailedReason`` +- ``NetworkErrorReason`` - ``InternalErrorReason`` +- ``TokenManagerError`` +- ``TokenStorageError`` -### Core Types +### Record management -- ``FieldValue`` -- ``RecordInfo`` -- ``UserInfo`` -- ``ZoneInfo`` +- ``CloudKitRecord`` +- ``RecordManaging`` +- ``CloudKitRecordCollection`` +- ``RecordTypeSet`` +- ``RecordTypeIterating`` + +### OpenAPI code generation +- +- +- ## See Also -- [CloudKit Web Services Documentation](https://developer.apple.com/documentation/cloudkitwebservices) +- [CloudKit Web Services documentation](https://developer.apple.com/documentation/cloudkitwebservices) - [Apple Developer Console](https://developer.apple.com) -- [Swift Package Manager](https://swift.org/package-manager/) +- [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index 6ebc30c9..a3d59078 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -1,24 +1,22 @@ # Generated Code Structure Analysis -A deep dive into the Swift code generated by swift-openapi-generator, with annotated examples showing type safety, architecture patterns, and integration points. +What `swift-openapi-generator` produces from `openapi.yaml`, how those types are organised, and where the hand-written wrapper plugs in. ## Overview -The swift-openapi-generator produces **10,476 lines** of type-safe Swift code from the CloudKit Web Services OpenAPI specification. This article provides a detailed analysis of the generated code structure, explaining how it achieves compile-time safety, handles HTTP operations, and integrates with MistKit's wrapper layer. - -## Generated File Organization - -### File Structure +Running `./Scripts/generate-openapi.sh` emits two files: ``` -Sources/MistKit/Generated/ -├── Client.swift (3,268 lines) - API client implementation -└── Types.swift (7,208 lines) - Type definitions +Sources/MistKitOpenAPI/ +├── Client.swift (~3,600 lines) +└── Types.swift (~8,600 lines) ``` -### File Headers +Both are committed to the repository and module-`internal`. `CloudKitService` and the rest of the wrapper layer treat them as a typed JSON-over-HTTP transport — they don't show up in MistKit's public surface. For setup of the pipeline that produces these files, see . -Both generated files include important header comments: +## File headers + +Both files begin with: ```swift // Generated by swift-openapi-generator, do not modify. @@ -27,698 +25,391 @@ Both generated files include important header comments: @_spi(Generated) import OpenAPIRuntime ``` -**Header elements explained:** - -- **`// Generated by swift-openapi-generator, do not modify.`** - Warning to developers not to edit generated code (changes would be overwritten) +- `do not modify` — manual edits are overwritten on the next regeneration. +- `periphery:ignore:all` — `mise exec -- periphery` skips the file. Generated code legitimately has unreferenced members for unused operations. +- `swift-format-ignore-file` — `mise exec -- swift-format` leaves the file untouched. The generator's output is already canonical. +- `@_spi(Generated)` — pulls in SPI helpers from `OpenAPIRuntime` that aren't part of its public API. -- **`// periphery:ignore:all`** - Instructs [Periphery](https://github.com/peripheryapp/periphery) (dead code analyzer) to skip this file, avoiding false positives for unused methods +## Client.swift -- **`// swift-format-ignore-file`** - Prevents [swift-format](https://github.com/apple/swift-format) from reformatting generated code +### APIProtocol -- **`@_spi(Generated) import OpenAPIRuntime`** - Imports internal/SPI (System Programming Interface) APIs from OpenAPIRuntime needed for generation - -## Client.swift: API Client Implementation - -### 1. APIProtocol: The Contract - -The `APIProtocol` defines the complete API surface as a Sendable protocol: +A `Sendable` protocol with one method per operation: ```swift -/// A type that performs HTTP operations defined by the OpenAPI document. internal protocol APIProtocol: Sendable { - /// Query Records - /// - /// Fetch records using a query with filters and sorting options - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. - /// - Remark: Generated from `#/paths//database/.../records/query/post(queryRecords)`. - func queryRecords(_ input: Operations.queryRecords.Input) async throws - -> Operations.queryRecords.Output - - /// Modify Records - /// - /// Create, update, or delete records (supports bulk operations) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. - func modifyRecords(_ input: Operations.modifyRecords.Input) async throws - -> Operations.modifyRecords.Output - - // ... 13 more operations (15 total) + func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output + func modifyRecords(_ input: Operations.modifyRecords.Input) async throws -> Operations.modifyRecords.Output + func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output + func fetchRecordChanges(_ input: Operations.fetchRecordChanges.Input) async throws -> Operations.fetchRecordChanges.Output + + func listZones(_ input: Operations.listZones.Input) async throws -> Operations.listZones.Output + func lookupZones(_ input: Operations.lookupZones.Input) async throws -> Operations.lookupZones.Output + func modifyZones(_ input: Operations.modifyZones.Input) async throws -> Operations.modifyZones.Output + func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output + + func listSubscriptions(_ input: Operations.listSubscriptions.Input) async throws -> Operations.listSubscriptions.Output + func lookupSubscriptions(_ input: Operations.lookupSubscriptions.Input) async throws -> Operations.lookupSubscriptions.Output + func modifySubscriptions(_ input: Operations.modifySubscriptions.Input) async throws -> Operations.modifySubscriptions.Output + + func discoverAllUserIdentities(_ input: Operations.discoverAllUserIdentities.Input) async throws -> Operations.discoverAllUserIdentities.Output + func discoverUserIdentities(_ input: Operations.discoverUserIdentities.Input) async throws -> Operations.discoverUserIdentities.Output + func lookupUsersByEmail(_ input: Operations.lookupUsersByEmail.Input) async throws -> Operations.lookupUsersByEmail.Output + func lookupUsersByRecordName(_ input: Operations.lookupUsersByRecordName.Input) async throws -> Operations.lookupUsersByRecordName.Output + + func lookupContacts(_ input: Operations.lookupContacts.Input) async throws -> Operations.lookupContacts.Output + func uploadAssets(_ input: Operations.uploadAssets.Input) async throws -> Operations.uploadAssets.Output + // … } ``` -**Key characteristics:** +Properties: -- ✅ **Sendable conformance**: Thread-safe by default for Swift 6 concurrency -- ✅ **Async/await**: All operations use modern concurrency -- ✅ **Typed errors**: `throws` enables typed error handling -- ✅ **Documentation**: Each method includes HTTP verb, path, and OpenAPI reference -- ✅ **Operation namespacing**: Input/Output types scoped to specific operations +- All operations are `async throws` — no completion handlers. +- Inputs and outputs are nested under their `Operations.` namespace, so there are no naming collisions between operations. +- Conformance is `Sendable`, which propagates `Sendable` requirements to `Client` and any implementation. -### 2. Client Struct: The Implementation +### Client -The `Client` struct implements `APIProtocol`: +The concrete implementation of `APIProtocol`: ```swift internal struct Client: APIProtocol { - /// The underlying HTTP client - private let client: UniversalClient - - /// Creates a new client - /// - Parameters: - /// - serverURL: The server URL (from Servers enum or custom) - /// - configuration: Client configuration options - /// - transport: HTTP transport layer (URLSession, custom, etc.) - /// - middlewares: Request/response middleware chain - internal init( - serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = [] - ) { - self.client = .init( - serverURL: serverURL, - configuration: configuration, - transport: transport, - middlewares: middlewares - ) - } + private let client: UniversalClient + + internal init( + serverURL: URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.client = .init( + serverURL: serverURL, + configuration: configuration, + transport: transport, + middlewares: middlewares + ) + } } ``` -**Architecture benefits:** +The constructor takes a transport (URLSession or custom) and an ordered middleware chain. `MistKit` builds one of these per dispatched operation, with the resolved `AuthenticationMiddleware` first in the chain. -- **Dependency injection**: Transport and middlewares are injectable for testing -- **Configuration flexibility**: Optional configuration with sensible defaults -- **Middleware support**: Enables authentication, logging, retry logic, etc. -- **Protocol abstraction**: Implementation hidden behind `APIProtocol` +### Operation implementation pattern -### 3. Operation Implementation Pattern - -Each operation follows a consistent pattern. Here's `queryRecords`: +Each operation pairs a serializer (typed `Input` → `HTTPRequest`) with a deserializer (`HTTPResponse` → typed `Output`). For `queryRecords`: ```swift -internal func queryRecords(_ input: Operations.queryRecords.Input) async throws - -> Operations.queryRecords.Output -{ - try await client.send( - input: input, - forOperation: Operations.queryRecords.id, - serializer: { input in - // Build HTTP request from typed input - let path = try converter.renderedPath( - template: "/database/{}/{}/{}/{}/records/query", - parameters: [ - input.path.version, - input.path.container, - input.path.environment, - input.path.database - ] - ) - - var request: HTTPTypes.HTTPRequest = .init( - soar_path: path, - method: .post - ) - - // Set headers - converter.setAcceptHeader( - in: &request.headerFields, - contentTypes: input.headers.accept - ) - - // Serialize body - let body: OpenAPIRuntime.HTTPBody? - switch input.body { - case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( - value, - headerFields: &request.headerFields, - contentType: "application/json; charset=utf-8" - ) - } - - return (request, body) - }, - deserializer: { response, responseBody in - // Deserialize HTTP response to typed output - switch response.status.code { - case 200: - let body = try await converter.getResponseBodyAsJSON( - Components.Schemas.QueryResponse.self, - from: responseBody, - transforming: { .json($0) } - ) - return .ok(.init(body: body)) - - case 400: - let body = try await converter.getResponseBodyAsJSON( - Components.Schemas.ErrorResponse.self, - from: responseBody, - transforming: { .json($0) } - ) - return .badRequest(.init(body: body)) - - // ... cases for 401, 403, 404, 409, 412, 413, etc. - - default: - return .undocumented( - statusCode: response.status.code, - .init(headerFields: response.headerFields, body: responseBody) - ) - } - } - ) +internal func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output { + try await client.send( + input: input, + forOperation: Operations.queryRecords.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/records/query", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database, + ] + ) + var request = HTTPTypes.HTTPRequest(soar_path: path, method: .post) + converter.setAcceptHeader(in: &request.headerFields, contentTypes: input.headers.accept) + + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case .json(let value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let body = try await converter.getResponseBodyAsJSON( + Components.Schemas.QueryResponse.self, + from: responseBody, + transforming: { .json($0) } + ) + return .ok(.init(body: body)) + case 400: return .badRequest(/* … */) + // … 401, 403, 404, 409, 412, 413 … + default: + return .undocumented( + statusCode: response.status.code, + .init(headerFields: response.headerFields, body: responseBody) + ) + } + } + ) } ``` -**Pattern breakdown:** - -1. **Serializer closure**: Converts typed `Input` → raw HTTP request -2. **Path rendering**: Type-safe path parameter substitution -3. **Header management**: Content-Type and Accept headers automatically set -4. **Body serialization**: Codable JSON encoding with proper content types -5. **Deserializer closure**: Converts raw HTTP response → typed `Output` -6. **Status code switching**: Each HTTP status becomes a distinct enum case -7. **Type-safe deserialization**: JSON decoded to specific schema types -8. **Undocumented fallback**: Handles unexpected status codes gracefully +Each branch of the response switch produces a distinct `Output` case. `.undocumented` is the always-present escape hatch. -### 4. Convenience Extensions +### Convenience extensions -For better ergonomics, generated code includes convenience overloads: +`APIProtocol` ships with overloads that take the input parts positionally so callers can avoid building an `Input` value by hand: ```swift extension APIProtocol { - /// Query Records - /// - /// Convenience overload with parameters instead of Input struct - internal func queryRecords( - path: Operations.queryRecords.Input.Path, - headers: Operations.queryRecords.Input.Headers = .init(), - body: Operations.queryRecords.Input.Body - ) async throws -> Operations.queryRecords.Output { - try await queryRecords(Operations.queryRecords.Input( - path: path, - headers: headers, - body: body - )) - } + internal func queryRecords( + path: Operations.queryRecords.Input.Path, + headers: Operations.queryRecords.Input.Headers = .init(), + body: Operations.queryRecords.Input.Body + ) async throws -> Operations.queryRecords.Output { + try await queryRecords(.init(path: path, headers: headers, body: body)) + } } ``` -**Usage comparison:** +MistKit's wrapper layer doesn't lean on these — it builds full `Input` values explicitly — but they're available to direct consumers of the generated client. -```swift -// Without convenience extension -let response = try await client.queryRecords(.init( - path: .init(version: "1", container: "iCloud.com.example", - environment: .production, database: ._public), - headers: .init(accept: [.json]), - body: .json(.init(query: .init(recordType: "User"))) -)) - -// With convenience extension (cleaner) -let response = try await client.queryRecords( - path: .init(version: "1", container: "iCloud.com.example", - environment: .production, database: ._public), - body: .json(.init(query: .init(recordType: "User"))) -) -``` - -### 5. Servers Enum - -Server URLs from the OpenAPI spec are codified as type-safe enums: +### Servers ```swift -/// Server URLs defined in the OpenAPI document internal enum Servers { - /// CloudKit Web Services API - internal enum Server1 { - internal static func url() throws -> Foundation.URL { - try Foundation.URL( - validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", - variables: [] - ) - } + internal enum Server1 { + internal static func url() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", + variables: [] + ) } + } } ``` -**Usage:** - -```swift -let serverURL = try Servers.Server1.url() -let client = Client( - serverURL: serverURL, - transport: URLSessionTransport() -) -``` - -This prevents hardcoded URL strings and enables server URL validation. - -## Types.swift: Type Definitions - -### 1. Components Namespace +`CloudKitService` uses `Servers.Server1.url()` rather than hard-coding the base URL. -All types are organized under the `Components` enum namespace: +## Types.swift -```swift -/// Types generated from the components section of the OpenAPI document -internal enum Components { - /// Types generated from `#/components/schemas` - internal enum Schemas { /* data models */ } - - /// Types generated from `#/components/parameters` - internal enum Parameters { /* parameter types */ } +Two top-level namespaces: `Components` (reusable schemas) and `Operations` (per-operation Input/Output trees). - /// Types generated from `#/components/requestBodies` - internal enum RequestBodies { /* (empty in CloudKit API) */ } +### Components.Schemas - /// Types generated from `#/components/responses` - internal enum Responses { /* reusable response types */ } -} -``` - -### 2. Schema Types: Data Models - -Schemas become structs with Codable, Hashable, and Sendable conformance: +Every `#/components/schemas/...` entry becomes a `struct` with `Codable, Hashable, Sendable`: ```swift -/// - Remark: Generated from `#/components/schemas/ZoneID` internal struct ZoneID: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneID/zoneName` - internal var zoneName: Swift.String? - - /// - Remark: Generated from `#/components/schemas/ZoneID/ownerName` - internal var ownerName: Swift.String? - - /// Creates a new `ZoneID` - /// - /// - Parameters: - /// - zoneName: Zone name - /// - ownerName: Owner name - internal init( - zoneName: Swift.String? = nil, - ownerName: Swift.String? = nil - ) { - self.zoneName = zoneName - self.ownerName = ownerName - } - - internal enum CodingKeys: String, CodingKey { - case zoneName - case ownerName - } + internal var zoneName: Swift.String? + internal var ownerName: Swift.String? + internal init(zoneName: Swift.String? = nil, ownerName: Swift.String? = nil) { … } + internal enum CodingKeys: String, CodingKey { case zoneName, ownerName } } ``` -**Generated features:** - -- ✅ Optional properties with nil defaults -- ✅ Explicit CodingKeys for JSON mapping -- ✅ Memberwise initializer with defaults -- ✅ Full protocol conformance (Codable, Hashable, Sendable) -- ✅ OpenAPI reference in documentation +Generated features per struct: -### 3. Enum Types: Type-Safe Constants +- Optional properties default to `nil`. +- Explicit `CodingKeys` enum (no reliance on synthesised behaviour). +- Memberwise initializer with defaults. +- `Sendable` for cross-actor use. +- A `- Remark:` doc comment pointing back at the OpenAPI ref. -String enums in OpenAPI become Swift enums with raw values: +String enums become Swift enums with the OpenAPI string as the raw value. CloudKit's filter comparators: ```swift -/// - Remark: Generated from `#/components/schemas/Filter/comparator` internal enum comparatorPayload: String, Codable, Hashable, Sendable, CaseIterable { - case EQUALS = "EQUALS" - case NOT_EQUALS = "NOT_EQUALS" - case LESS_THAN = "LESS_THAN" - case LESS_THAN_OR_EQUALS = "LESS_THAN_OR_EQUALS" - case GREATER_THAN = "GREATER_THAN" - case GREATER_THAN_OR_EQUALS = "GREATER_THAN_OR_EQUALS" - case NEAR = "NEAR" - case CONTAINS_ALL_TOKENS = "CONTAINS_ALL_TOKENS" - case IN = "IN" - case NOT_IN = "NOT_IN" - case CONTAINS_ANY_TOKENS = "CONTAINS_ANY_TOKENS" - case LIST_CONTAINS = "LIST_CONTAINS" - case NOT_LIST_CONTAINS = "NOT_LIST_CONTAINS" - case BEGINS_WITH = "BEGINS_WITH" - case NOT_BEGINS_WITH = "NOT_BEGINS_WITH" - case LIST_MEMBER_BEGINS_WITH = "LIST_MEMBER_BEGINS_WITH" - case NOT_LIST_MEMBER_BEGINS_WITH = "NOT_LIST_MEMBER_BEGINS_WITH" + case EQUALS, NOT_EQUALS, LESS_THAN, LESS_THAN_OR_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUALS, + NEAR, CONTAINS_ALL_TOKENS, IN, NOT_IN, CONTAINS_ANY_TOKENS, + LIST_CONTAINS, NOT_LIST_CONTAINS, + BEGINS_WITH, NOT_BEGINS_WITH, + LIST_MEMBER_BEGINS_WITH, NOT_LIST_MEMBER_BEGINS_WITH } ``` -**Type safety benefits:** - -- ✅ Autocomplete for all valid values -- ✅ Compile-time checking (can't use invalid comparator) -- ✅ CaseIterable for enumeration -- ✅ Codable for automatic JSON encoding/decoding +This eliminates string typos at call sites. -**Before (string literals):** -```swift -// Easy to typo, no autocomplete -let filter = Filter(comparator: "GRETER_THAN", ...) // Typo! -``` +### Field values: request vs response -**After (type-safe enum):** -```swift -// Autocomplete, compile-time safety -let filter = Filter(comparator: .GREATER_THAN, ...) -``` +CloudKit's API is asymmetric — request bodies omit the `type` field, response bodies sometimes include it — so the OpenAPI schema models two separate types and the generator emits both: -### 4. Error Response Types +- `Components.Schemas.FieldValueRequest` — no `type` field. +- `Components.Schemas.FieldValueResponse` — optional `type` field. +- `Components.Schemas.RecordRequest` — fields keyed to `FieldValueRequest`. +- `Components.Schemas.RecordResponse` — fields keyed to `FieldValueResponse`. -Error responses are fully typed with nested enums for error codes: +The compiler refuses to put a response value in a request. MistKit's wrapper hides the split behind a single domain ``FieldValue`` enum and converts at the boundary: -```swift -/// - Remark: Generated from `#/components/schemas/ErrorResponse` -internal struct ErrorResponse: Codable, Hashable, Sendable { - internal var uuid: Swift.String? - - /// Server error code enum - internal enum serverErrorCodePayload: String, Codable, Hashable, Sendable { - case ACCESS_DENIED = "ACCESS_DENIED" - case ATOMIC_ERROR = "ATOMIC_ERROR" - case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" - case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" - case BAD_REQUEST = "BAD_REQUEST" - case CONFLICT = "CONFLICT" - case EXISTS = "EXISTS" - case INTERNAL_ERROR = "INTERNAL_ERROR" - case NOT_FOUND = "NOT_FOUND" - case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" - case THROTTLED = "THROTTLED" - case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" - case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" - case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" - } +- Outgoing: `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` converts ``FieldValue`` → `FieldValueRequest`. +- Incoming: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` converts `FieldValueResponse` → ``FieldValue``. - internal var serverErrorCode: serverErrorCodePayload? - internal var reason: Swift.String? - internal var redirectURL: Swift.String? -} -``` +### Error responses -**Error handling example:** +CloudKit's HTTP error responses share one body schema regardless of status code. The OpenAPI spec models this as a single unified `Failure` response: ```swift -do { - let response = try await client.queryRecords(...) -} catch { - // Type-safe error handling - if case let .badRequest(badRequest) = response, - case let .json(errorResponse) = try badRequest.body.json, - errorResponse.serverErrorCode == .AUTHENTICATION_FAILED { - print("Authentication failed: \(errorResponse.reason ?? "Unknown")") - } +internal struct Failure: Codable, Hashable, Sendable { + internal var uuid: Swift.String? + internal enum serverErrorCodePayload: String, Codable, Hashable, Sendable { + case ACCESS_DENIED, ATOMIC_ERROR, AUTHENTICATION_FAILED, AUTHENTICATION_REQUIRED, + BAD_REQUEST, CONFLICT, EXISTS, INTERNAL_ERROR, NOT_FOUND, + QUOTA_EXCEEDED, THROTTLED, TRY_AGAIN_LATER, + VALIDATING_REFERENCE_ERROR, ZONE_NOT_FOUND + } + internal var serverErrorCode: serverErrorCodePayload? + internal var reason: Swift.String? + internal var redirectURL: Swift.String? } ``` -### 5. Parameters: Path and Query Parameters +The wrapper's `CloudKitResponseType` protocol (`Sources/MistKit/OpenAPI/CloudKitResponseType.swift`) plus its per-operation conformances under `Sources/MistKit/OpenAPI/Operations/Operations.*.Output.swift` map each operation's status-keyed response cases into ``CloudKitError`` cases so callers never see the generated payload type. `CloudKitResponseProcessor` (`Sources/MistKit/CloudKitService/CloudKitResponseProcessor*.swift`) handles the dispatching side. -Parameters are defined as typealiases or enums: +### Parameters ```swift internal enum Parameters { - /// Protocol version - /// - Remark: Generated from `#/components/parameters/version` - internal typealias version = Swift.String - - /// Container ID (begins with "iCloud.") - /// - Remark: Generated from `#/components/parameters/container` - internal typealias container = Swift.String - - /// Container environment - /// - Remark: Generated from `#/components/parameters/environment` - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - - /// Database scope - /// - Remark: Generated from `#/components/parameters/database` - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" // Leading underscore (Swift keyword) - case _private = "private" // Leading underscore (Swift keyword) - case shared = "shared" - } + internal typealias version = Swift.String + internal typealias container = Swift.String + + internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development + case production + } + + internal enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" // `public` is a keyword — generator prefixes with `_` + case _private = "private" + case shared + } } ``` -**Keyword escaping:** +MistKit's public ``Database`` enum is *not* the same type. The wrapper's ``Database`` is richer — `.public` carries a ``PublicAuthPreference`` — and its `pathSegment` is what gets passed to the generated `_public` / `_private` / `shared` parameter at dispatch time. -Notice `_public` and `_private` have leading underscores because `public` and `private` are Swift keywords. The generator handles this automatically. +### Operations. -### 6. Operations Namespace - -Each API operation gets a dedicated namespace with Input and Output types: +Each operation has an `Input` / `Output` tree: ```swift internal enum Operations { - internal enum queryRecords { - internal static let id: Swift.String = "queryRecords" - - // INPUT TYPES - internal struct Input: Sendable, Hashable { - /// Path parameters - internal struct Path: Sendable, Hashable { - internal var version: Components.Parameters.version - internal var container: Components.Parameters.container - internal var environment: Components.Parameters.environment - internal var database: Components.Parameters.database - } - - /// Headers - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType< - Operations.queryRecords.AcceptableContentType - >] - - internal init( - accept: [OpenAPIRuntime.AcceptHeaderContentType< - Operations.queryRecords.AcceptableContentType - >] = .defaultValues() - ) { - self.accept = accept - } - } - - /// Request body - internal enum Body: Sendable, Hashable { - case json(Components.Schemas.QueryRequest) - } - - internal var path: Path - internal var headers: Headers - internal var body: Body - } + internal enum queryRecords { + internal static let id: Swift.String = "queryRecords" + + internal struct Input: Sendable, Hashable { + internal struct Path: Sendable, Hashable { + internal var version: Components.Parameters.version + internal var container: Components.Parameters.container + internal var environment: Components.Parameters.environment + internal var database: Components.Parameters.database + } + internal struct Headers: Sendable, Hashable { + internal var accept: [OpenAPIRuntime.AcceptHeaderContentType< + Operations.queryRecords.AcceptableContentType + >] + } + internal enum Body: Sendable, Hashable { + case json(Components.Schemas.QueryRequest) + } + internal var path: Path + internal var headers: Headers + internal var body: Body + } - // OUTPUT TYPES - internal enum Output: Sendable, Hashable { - /// 200 OK response - internal struct Ok: Sendable, Hashable { - internal enum Body: Sendable, Hashable { - case json(Components.Schemas.QueryResponse) - - internal var json: Components.Schemas.QueryResponse { - get throws { - switch self { - case let .json(body): return body - } - } - } - } - internal var body: Body - } - - /// Response cases for each HTTP status - case ok(Ok) - case badRequest(Components.Responses.BadRequest) - case unauthorized(Components.Responses.Unauthorized) - case forbidden(Components.Responses.Forbidden) - case notFound(Components.Responses.NotFound) - case conflict(Components.Responses.Conflict) - case preconditionFailed(Components.Responses.PreconditionFailed) - case requestEntityTooLarge(Components.Responses.RequestEntityTooLarge) - case undocumented(statusCode: Int, UndocumentedPayload) + internal enum Output: Sendable, Hashable { + internal struct Ok: Sendable, Hashable { + internal enum Body: Sendable, Hashable { + case json(Components.Schemas.QueryResponse) + internal var json: Components.Schemas.QueryResponse { get throws { … } } } + internal var body: Body + } + case ok(Ok) + case badRequest(Components.Responses.BadRequest) + case unauthorized(Components.Responses.Unauthorized) + case forbidden(Components.Responses.Forbidden) + case notFound(Components.Responses.NotFound) + case conflict(Components.Responses.Conflict) + case preconditionFailed(Components.Responses.PreconditionFailed) + case requestEntityTooLarge(Components.Responses.RequestEntityTooLarge) + case undocumented(statusCode: Int, UndocumentedPayload) } + } } ``` -**Type hierarchy:** +Two patterns to note: -``` -Operations -└── queryRecords - ├── id (operation identifier) - ├── Input - │ ├── Path (path parameters) - │ ├── Headers (HTTP headers) - │ └── Body (request body) - └── Output (enum of response cases) - ├── ok(Ok) - ├── badRequest(...) - ├── unauthorized(...) - └── undocumented(...) -``` +1. **One enum case per HTTP status.** The compiler forces exhaustive handling — you can't forget a 412 or a 413. `.undocumented` catches anything the spec doesn't model. +2. **Throwing computed properties on body enums.** `okResponse.body.json` is a `get throws` — the only case is `.json`, but the pattern generalises to operations that allow multiple content types. -This deep nesting prevents naming conflicts and keeps types organized by operation. +## Why the wrapper folds this away -### 7. Response Body Access Pattern - -Generated response types use throwing computed properties for safe unwrapping: +A typical direct call against the generated client: ```swift -internal enum Body: Sendable, Hashable { - case json(Components.Schemas.QueryResponse) - - /// Safe accessor throwing if wrong case - internal var json: Components.Schemas.QueryResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } -} -``` - -**Usage:** - -```swift -let response = try await client.queryRecords(...) +let response = try await client.queryRecords( + path: .init(version: "1", container: "iCloud.com.example", + environment: .production, database: ._public), + body: .json(.init(query: .init(recordType: "User"))) +) switch response { -case let .ok(okResponse): - // Type-safe access to response body - let queryResponse = try okResponse.body.json - for record in queryResponse.records ?? [] { - print(record) - } - -case let .badRequest(errorResponse): - let error = try errorResponse.body.json - print("Error: \(error.serverErrorCode)") - -default: - print("Unexpected response") +case .ok(let ok): + let body = try ok.body.json + for record in body.records ?? [] { /* read record */ } +case .badRequest(let resp): + let err = try resp.body.json + throw CloudKitError.httpErrorWithDetails( + statusCode: 400, + serverErrorCode: err.serverErrorCode?.rawValue, + reason: err.reason + ) +case .undocumented(let code, _): + throw CloudKitError.httpError(statusCode: code) +// … five more cases … } ``` -## Type Safety Comparison +That's correct but tedious for every call site. ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. -### Before: Manual HTTP + JSON +## Integration with the wrapper -```swift -// Manual HTTP client - error-prone, no compile-time safety - -let urlString = "https://api.apple-cloudkit.com/database/1/" + - "\(container)/production/public/records/query" -var request = URLRequest(url: URL(string: urlString)!) -request.httpMethod = "POST" -request.setValue("application/json", forHTTPHeaderField: "Content-Type") - -// Easy to make mistakes - typos, wrong nesting, missing fields -let json: [String: Any] = [ - "query": [ - "recordType": "User", - "filterBy": [ // Typo: should be array of filter objects - "fieldName": "age", - "comparator": "GRETER_THAN", // Typo: GREATER_THAN - "fieldValue": ["value": 18] - ] - ] -] - -let data = try JSONSerialization.data(withJSONObject: json) -request.httpBody = data - -let (responseData, _) = try await URLSession.shared.data(for: request) +Three integration points connect the wrapper to the generated layer: -// Manual parsing - type casting everywhere -let responseJSON = try JSONSerialization.jsonObject(with: responseData) as! [String: Any] -let records = responseJSON["records"] as? [[String: Any]] ?? [] -``` - -**Problems:** +### 1. CloudKitService builds a Client per dispatch -- ❌ No compile-time verification -- ❌ Easy to typo field names -- ❌ Wrong types accepted (e.g., single dict instead of array) -- ❌ Typos in enum values ("GRETER_THAN") -- ❌ Manual JSON serialization/deserialization -- ❌ Type casting hell -- ❌ No autocomplete support +Operations under `Sources/MistKit/CloudKitService/CloudKitService+*.swift` instantiate the generated `Client` with `Servers.Server1.url()`, the configured transport, and a middleware chain headed by `AuthenticationMiddleware`. A fresh client per dispatch keeps each request's authenticator independent and makes per-call ``Database`` selection straightforward. -### After: Generated Type-Safe Client +### 2. AuthenticationMiddleware delegates to Authenticator ```swift -// Type-safe generated client - compile-time safety, autocomplete - -let response = try await client.queryRecords( - path: .init( - version: "1", - container: container, - environment: .production, // Enum - can't typo - database: ._public // Enum - can't typo - ), - body: .json(.init( - query: .init( - recordType: "User", - filterBy: [ // Correctly typed as array - .init( - fieldName: "age", - comparator: .GREATER_THAN, // Enum - autocomplete, can't typo - fieldValue: .init(value: .int64(18)) // Type-safe value - ) - ] - ) - )) -) - -// Type-safe response handling -switch response { -case let .ok(okResponse): - let queryResponse = try okResponse.body.json - // queryResponse is strongly typed as Components.Schemas.QueryResponse - for record in queryResponse.records ?? [] { - // record is strongly typed - print(record.recordName) - } - -case let .badRequest(error): - let errorResponse = try error.body.json - // errorResponse is strongly typed as Components.Schemas.ErrorResponse - if errorResponse.serverErrorCode == .AUTHENTICATION_FAILED { - print("Auth failed: \(errorResponse.reason ?? "")") +internal struct AuthenticationMiddleware: ClientMiddleware { + internal let tokenManager: any TokenManager + + internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + guard let authenticator = try await tokenManager.currentAuthenticator() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) } - -default: - print("Unexpected response") + var modifiedRequest = request + var modifiedBody = body + try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) + return try await next(modifiedRequest, modifiedBody, baseURL) + } } ``` -**Benefits:** +The middleware is intentionally tiny — adding a new authentication scheme means writing a new ``Authenticator``, not touching this file. Details in . -- ✅ Compile-time verification of request structure -- ✅ Autocomplete for all fields and enums -- ✅ Impossible to typo enum values -- ✅ Correct types enforced by compiler -- ✅ Automatic JSON serialization/deserialization -- ✅ Strongly typed responses -- ✅ Exhaustive error handling +### 3. FieldValue lives outside the generated layer -## Swift Language Features +The single domain ``FieldValue`` enum is hand-written; the generated `FieldValueRequest` and `FieldValueResponse` types are kept inside the wrapper. Both directions are converted at the boundary so call sites only ever see ``FieldValue``. -### 1. Conditional Compilation for Platform Support +## Cross-platform notes -Generated code handles platform differences: +Generated code accommodates Linux's older Foundation by gating imports: ```swift #if os(Linux) @@ -732,409 +423,13 @@ import struct Foundation.Date #endif ``` -**Why:** - -- Linux doesn't have full Sendable conformance for Foundation types in older versions -- `@preconcurrency` suppresses concurrency warnings on Linux -- Enables cross-platform compatibility (macOS, iOS, Linux) - -### 2. Sendable Conformance for Concurrency Safety - -All generated types conform to Sendable: - -```swift -internal protocol APIProtocol: Sendable { ... } -internal struct Client: APIProtocol { ... } -internal struct Input: Sendable, Hashable { ... } -internal enum Output: Sendable, Hashable { ... } -``` - -**Benefits:** - -- ✅ Safe to pass across actor boundaries -- ✅ Safe to use in async/await contexts -- ✅ Compile-time data race prevention (Swift 6) -- ✅ No runtime concurrency overhead - -### 3. Async/Await Throughout - -All API methods use modern Swift concurrency: - -```swift -func queryRecords(_ input: Input) async throws -> Output -``` - -**Benefits:** - -- ✅ Structured concurrency support -- ✅ Automatic task cancellation propagation -- ✅ Better error handling than completion closures -- ✅ TaskGroup support for parallel operations - -### 4. Throwing Computed Properties - -Safe access to enum associated values: - -```swift -internal var json: Components.Schemas.QueryResponse { - get throws { - switch self { - case let .json(body): - return body - } - } -} -``` - -**Usage:** - -```swift -// Safe unwrapping with throws -let queryResponse = try okResponse.body.json - -// Compiler enforces error handling -``` - -## Integration with MistKit Wrapper - -### 1. MistKitClient Wraps Generated Client - -```swift -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -internal struct MistKitClient { - /// The underlying OpenAPI client - internal let client: Client // Generated Client struct - - internal init( - configuration: MistKitConfiguration, - transport: any ClientTransport - ) throws { - let tokenManager = try configuration.createTokenManager() - - self.client = Client( - serverURL: configuration.serverURL, - transport: transport, - middlewares: [ - AuthenticationMiddleware(tokenManager: tokenManager), - LoggingMiddleware() - ] - ) - } -} -``` - -**Wrapper responsibilities:** - -- ✅ Configuration management -- ✅ Token manager creation -- ✅ Middleware injection -- ✅ Server URL construction -- ✅ Higher-level convenience APIs - -### 2. AuthenticationMiddleware Integration - -The generated client's middleware support enables authentication: - -```swift -internal struct AuthenticationMiddleware: ClientMiddleware { - private let tokenManager: any TokenManager - - func intercept( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) - ) async throws -> (HTTPResponse, HTTPBody?) { - // Add CloudKit authentication headers/query parameters - var authenticatedRequest = request - - let credentials = try await tokenManager.credentials() - // Add authentication based on credentials type - // ... - - return try await next(authenticatedRequest, body, baseURL) - } -} -``` - -**Middleware chain flow:** - -``` -Request - ↓ -AuthenticationMiddleware (adds auth) - ↓ -LoggingMiddleware (logs request) - ↓ -Transport (URLSession) - ↓ -HTTP Network - ↓ -Response -``` - -### 3. Custom Type Override: CustomFieldValue - -The configuration specifies a type override: - -```yaml -typeOverrides: - schemas: - FieldValue: CustomFieldValue -``` - -Generated code references the custom type: - -```swift -// In generated Types.swift -internal var fieldValue: CustomFieldValue? // Not Components.Schemas.FieldValue -``` - -**MistKit implementation** (`CustomFieldValue.swift`): - -```swift -internal struct CustomFieldValue: Codable, Hashable, Sendable { - // Custom implementation for CloudKit-specific field value handling - internal enum CustomFieldValuePayload { - case string(String) - case int64(Int64) - case double(Double) - case timestamp(Date) - case bytes(Data) - case reference(RecordReference) - case asset(Asset) - case location(Location) - case stringList([String]) - case int64List([Int64]) - case doubleList([Double]) - case timestampList([Date]) - case referenceList([RecordReference]) - case assetList([Asset]) - } - - internal var payload: CustomFieldValuePayload - // Custom Codable implementation... -} -``` - -This allows MistKit to provide CloudKit-specific field value semantics while using the generated code. - -## Architecture Patterns - -### 1. Namespace Organization - -``` -Client.swift -├── APIProtocol (protocol) -├── Client (struct) -├── APIProtocol extension (convenience methods) -└── Servers (enum) - -Types.swift -├── Components (namespace enum) -│ ├── Schemas (data models) -│ ├── Parameters (parameter types) -│ ├── RequestBodies (empty) -│ └── Responses (response types) -└── Operations (namespace enum) - ├── queryRecords - │ ├── id - │ ├── Input - │ └── Output - ├── modifyRecords - │ ├── id - │ ├── Input - │ └── Output - └── ... (13 more operations) -``` - -**Benefits:** - -- ✅ No naming conflicts between operations -- ✅ Clear ownership of types -- ✅ Logical grouping -- ✅ Easy navigation - -### 2. Enum-Based Response Handling - -```swift -internal enum Output: Sendable, Hashable { - case ok(Ok) - case badRequest(BadRequest) - case unauthorized(Unauthorized) - // ... more cases - case undocumented(statusCode: Int, UndocumentedPayload) -} -``` - -**Pattern benefits:** - -- ✅ Exhaustive switch coverage required by compiler -- ✅ Each status code is a distinct type -- ✅ Forces explicit error handling -- ✅ Fallback for unexpected responses (undocumented) - -**Usage:** - -```swift -switch response { -case .ok(let okResponse): - // Handle success - -case .badRequest(let error): - // Handle 400 - -case .unauthorized(let error): - // Handle 401 - -case .undocumented(let statusCode, _): - // Handle unexpected status - print("Unexpected status: \(statusCode)") -} -``` - -### 3. Protocol-Oriented Design - -```swift -// Protocol defines contract -internal protocol APIProtocol: Sendable { - func queryRecords(...) async throws -> Output -} - -// Struct implements protocol -internal struct Client: APIProtocol { - // Implementation -} - -// Middleware uses protocol -internal struct AuthenticationMiddleware: ClientMiddleware { - // Works with any APIProtocol implementation -} -``` - -**Benefits:** - -- ✅ Easy to mock for testing -- ✅ Flexible implementation swapping -- ✅ Clear separation of interface and implementation - -## Performance Considerations - -### 1. Struct Value Semantics - -All types are structs (except protocols and enums): - -```swift -internal struct Client { ... } -internal struct Input { ... } -internal struct ZoneID { ... } -``` - -**Benefits:** - -- ✅ No heap allocation for most types -- ✅ Copy-on-write semantics -- ✅ Better cache locality -- ✅ Automatic memory management - -### 2. Lazy JSON Parsing - -Response bodies use streaming: - -```swift -let body = try await converter.getResponseBodyAsJSON( - Components.Schemas.QueryResponse.self, - from: responseBody // HTTPBody (streaming) -) -``` - -**Benefits:** - -- ✅ Doesn't buffer entire response in memory -- ✅ Efficient for large responses -- ✅ Progressive parsing - -### 3. Minimal Allocations - -Generated code avoids unnecessary allocations: - -```swift -// Reuses converter instance -private var converter: Converter { - client.converter -} - -// Uses inout for mutations -converter.setAcceptHeader( - in: &request.headerFields, // inout - no copy - contentTypes: input.headers.accept -) -``` - -## Testing Considerations - -### 1. Protocol Abstraction Enables Mocking - -```swift -// Test with mock implementation -struct MockClient: APIProtocol { - func queryRecords(_ input: Input) async throws -> Output { - // Return canned response - return .ok(.init(body: .json(mockQueryResponse))) - } -} - -// Use in tests -let mockClient = MockClient() -let wrapper = MistKitClient(client: mockClient) -``` - -### 2. Transport Injection - -```swift -// Custom transport for testing -struct MockTransport: ClientTransport { - func send( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String - ) async throws -> (HTTPResponse, HTTPBody?) { - // Return mock response - } -} - -let client = Client( - serverURL: testServerURL, - transport: MockTransport() -) -``` - -### 3. Middleware Testing - -```swift -// Test middleware in isolation -let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - -let (response, body) = try await middleware.intercept( - request, - body: nil, - baseURL: baseURL, - operationID: "queryRecords", - next: { req, body, url in - // Verify authentication was added - XCTAssertNotNil(req.headerFields[.authorization]) - return (mockResponse, mockBody) - } -) -``` +The wrapper layer follows the same convention. WASI excludes `URLSessionTransport` entirely — non-WASI builds get URLSession-backed convenience initializers on ``CloudKitService``; WASI callers pass a `ClientTransport` explicitly to the generic initializer. ## See Also - -- [OpenAPI Runtime Documentation](https://github.com/apple/swift-openapi-runtime) -- [HTTPTypes Documentation](https://github.com/apple/swift-http-types) -- ``MistKitClient`` -- ``AuthenticationMiddleware`` -- ``CustomFieldValue`` +- +- +- +- [swift-openapi-runtime](https://github.com/apple/swift-openapi-runtime) +- [HTTPTypes](https://github.com/apple/swift-http-types) diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md b/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md index bcf235d8..75d5079e 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md @@ -1,1025 +1,274 @@ # Development Workflow for Generated Code -A comprehensive guide to managing swift-openapi-generator code throughout the development lifecycle, including version control, updates, CI/CD integration, and code review best practices. +When to regenerate, what to commit, and how to review changes when `openapi.yaml` moves. ## Overview -MistKit uses a **pre-generation workflow** where generated code is committed to version control. This article covers the complete development workflow for working with generated code, from initial setup through updates, reviews, and deployment. +MistKit commits its generated OpenAPI client code (`Sources/MistKitOpenAPI/Client.swift`, `Types.swift`) so that consumers don't need any generation tooling. The trade-off is that contributors take on a small discipline: regenerate after editing `openapi.yaml`, commit the spec change and the regenerated files together, and review the diff like any other code. -## Workflow Philosophy: Pre-Generation vs. Build Plugin +This article walks the workflow. For the toolchain itself see ; for what the generated code looks like see . -### MistKit's Approach: Pre-Generation +## Pre-generation vs. build plugin -``` -Developer Machine Git Repository Consumer Machine -───────────────── ────────────── ──────────────── -1. Edit openapi.yaml -2. Run generate script → 3. Commit generated code → 4. swift build - Sources/Generated/*.swift ↓ - Uses existing - generated code -``` - -**Advantages:** - -- ✅ **Fast consumer builds**: No generation during build time -- ✅ **No tool dependencies for consumers**: swift-openapi-generator not required -- ✅ **Reviewable changes**: Generated code visible in pull requests -- ✅ **Predictable builds**: Same generated code across all environments -- ✅ **Better IDE support**: Generated code always available for autocomplete -- ✅ **Easier debugging**: Can inspect and trace through generated code - -**Disadvantages:** - -- ⚠️ **Developer discipline required**: Must remember to regenerate after spec changes -- ⚠️ **Larger git diffs**: Generated code changes appear in commits -- ⚠️ **Potential for mistakes**: Forgetting to regenerate can cause drift - -### Alternative: Build Plugin (Not Used) +MistKit pre-generates. The alternative — wiring the generator in as a SwiftPM build plugin — was considered and rejected: -``` -Developer Machine Git Repository Consumer Machine -───────────────── ────────────── ──────────────── -1. Edit openapi.yaml → 2. Commit spec only → 3. swift build - ↓ - Generates code - during build -``` - -**Why MistKit doesn't use this:** - -- ❌ Requires consumers to install swift-openapi-generator -- ❌ Slower builds for everyone -- ❌ Generated code not visible in code reviews -- ❌ Harder to debug (generated code in derived data) -- ❌ IDE autocomplete delays while generating +| | Pre-generation (MistKit) | Build plugin | +| --- | --- | --- | +| Consumer needs `swift-openapi-generator` | No | Yes | +| Consumer build time | Fast | Adds generation step | +| Generated diffs visible in PRs | Yes | No | +| IDE indexes generated code | Immediately | After plugin runs | +| Contributor discipline | Regenerate after spec edits | None | -## Development Workflow +The discipline cost is small (one script, one commit) and is well-suited to a small core team. The consumer-side cost of a build plugin is paid by every downstream user. -### 1. Initial Project Setup - -When starting a new MistKit-based project or contributing to MistKit: +## Initial setup ```bash -# 1. Clone the repository -git clone https://github.com/your-org/MistKit.git +git clone https://github.com/brightdigit/MistKit.git cd MistKit -# 2. Install Mint (if not already installed) -brew install mint # macOS -# or follow Linux installation instructions - -# 3. Bootstrap development tools -mint bootstrap -m Mintfile - -# 4. Verify generated code exists -ls -la Sources/MistKit/Generated/ -# Should see: Client.swift, Types.swift +# Install the pinned tools (mise reads mise.toml) +mise install -# 5. Build to verify everything works +# Build to verify the committed generated code compiles in your environment swift build -# 6. Run tests +# Run tests swift test ``` -**Expected output:** +You shouldn't need to regenerate on a fresh clone — the generated files are already there. -``` -✅ Sources/MistKit/Generated/Client.swift (exists) -✅ Sources/MistKit/Generated/Types.swift (exists) -✅ Build succeeded -✅ Tests passed -``` - -### 2. Making OpenAPI Specification Changes - -#### Step 1: Edit the OpenAPI Specification - -```bash -# Open the OpenAPI spec in your editor -vim openapi.yaml -# or -code openapi.yaml -``` +## Editing the OpenAPI spec -**Common changes:** +### 1. Edit openapi.yaml -- Adding new endpoints -- Modifying request/response schemas -- Updating parameter definitions -- Adding/changing enum values -- Updating documentation strings +Common edits: -#### Step 2: Validate the OpenAPI Spec (Optional but Recommended) - -```bash -# Install openapi-spec-validator (if not installed) -pip install openapi-spec-validator - -# Validate the spec -openapi-spec-validator openapi.yaml -``` - -**Expected output:** - -``` -✅ openapi.yaml is valid -``` +- A new path or operation. +- A schema property added, removed, or retyped. +- A new enum case on an existing string enum (filter comparator, server error code, …). +- A documentation string. -#### Step 3: Regenerate Client Code +### 2. Regenerate ```bash -# Run the generation script ./Scripts/generate-openapi.sh ``` -**Expected output:** +The script puts mise-managed binaries on `$PATH`, then runs `swift-openapi-generator generate` with `openapi-generator-config.yaml`. Both `Sources/MistKitOpenAPI/Client.swift` and `Sources/MistKitOpenAPI/Types.swift` are overwritten. -``` -🔄 Generating OpenAPI code... -✅ OpenAPI code generation complete! -``` +### 3. Update the wrapper -**What happens:** +The hand-written layer often needs to follow. Common follow-ups: -1. Mint ensures swift-openapi-generator@1.10.0 is installed -2. Generator reads `openapi.yaml` and `openapi-generator-config.yaml` -3. Generates new `Client.swift` and `Types.swift` files -4. Overwrites existing files in `Sources/MistKit/Generated/` +- New operation → add a method on ``CloudKitService`` (typically a new file under `Sources/MistKit/CloudKitService/CloudKitService+*.swift`). +- New schema → add a domain model under `Sources/MistKit/Models/` and the conversion under `Sources/MistKit/Models/FieldValues/` (response → domain) or `Sources/MistKit/OpenAPI/Components/` (domain → request). +- Renamed enum case → fix any switch statements that referenced the old name. The compiler will list every site. +- Removed schema → remove any wrapper code that referenced it. -#### Step 4: Verify Generated Code Compiles +### 4. Tests + lint ```bash -# Clean build to ensure no cached artifacts -swift package clean - -# Build with fresh generated code swift build -``` - -**If build fails:** - -1. Check for breaking changes in generated types -2. Update wrapper code (MistKitClient, etc.) to match new types -3. Fix compilation errors -4. Re-run `swift build` - -#### Step 5: Update Tests - -```bash -# Run existing tests swift test -# Add new tests for new functionality -# Edit Tests/MistKitTests/*.swift +mise exec -- swift-format -i -r Sources/ Tests/ +mise exec -- swiftlint ``` -#### Step 6: Review Changes +Or the full pipeline: ```bash -# See what changed in generated code -git diff Sources/MistKit/Generated/ - -# See what changed in OpenAPI spec -git diff openapi.yaml +./Scripts/lint.sh ``` -**Review checklist:** - -- [ ] Generated code compiles successfully -- [ ] Tests pass -- [ ] New types match OpenAPI schema expectations -- [ ] No unexpected changes in generated code -- [ ] Documentation comments are accurate - -### 3. Committing Changes +### 5. Commit -#### Commit Strategy: Separate Commits for Clarity - -**Option A: Two commits (recommended for large changes)** +Both the spec change and the regenerated files should land in the same commit (or back-to-back commits) so `git bisect` and code review can tell what produced the change: ```bash -# Commit 1: OpenAPI spec change -git add openapi.yaml -git commit -m "feat: add uploadAssets endpoint to OpenAPI spec" - -# Commit 2: Generated code update -git add Sources/MistKit/Generated/ -git commit -m "chore: regenerate client code for uploadAssets endpoint - -Generated with [Claude Code](https://claude.com/claude-code) - -Co-Authored-By: Claude " +git add openapi.yaml Sources/MistKitOpenAPI/ Sources/MistKit/… # wrapper updates +git commit -m "feat(records): add /records/lookupChanges endpoint" ``` -**Option B: Single commit (for small changes)** - -```bash -# Commit both together -git add openapi.yaml Sources/MistKit/Generated/ -git commit -m "feat: add uploadAssets endpoint - -- Added /assets/upload endpoint to OpenAPI spec -- Regenerated client code with swift-openapi-generator +## Commit message style -Generated with [Claude Code](https://claude.com/claude-code) - -Co-Authored-By: Claude " -``` - -#### Commit Message Format +MistKit follows the conventional-commits flavour visible in `git log`: ``` (): - -Generated with [Claude Code](https://claude.com/claude-code) - -Co-Authored-By: Claude -``` - -**Types:** -- `feat`: New feature or endpoint -- `fix`: Bug fix in OpenAPI spec -- `chore`: Regenerating code without spec changes -- `docs`: Documentation updates in OpenAPI spec -- `refactor`: Restructuring schemas without functional changes - -**Examples:** - -```bash -# New endpoint -git commit -m "feat(records): add bulk delete operation" - -# Schema change -git commit -m "fix(schemas): correct FieldValue type definition" - -# Regeneration only (e.g., after generator version update) -git commit -m "chore: regenerate code with swift-openapi-generator 1.10.0" - -# Documentation update -git commit -m "docs(openapi): improve error response descriptions" -``` - -### 4. Code Review Process - -#### What to Review - -When reviewing pull requests with generated code changes: - -**1. OpenAPI Spec Changes (Primary Focus)** - -```diff -# openapi.yaml - paths: -+ /database/{version}/{container}/{environment}/{database}/assets/upload: -+ post: -+ summary: Upload Assets -+ operationId: uploadAssets -+ ... -``` - -**Review checklist:** - -- [ ] Is the endpoint path correct? -- [ ] Are parameter types appropriate? -- [ ] Is the schema well-defined? -- [ ] Are error responses documented? -- [ ] Is the operation ID meaningful? - -**2. Generated Code Changes (Secondary Focus)** - -```diff -# Sources/MistKit/Generated/Types.swift -+ internal enum uploadAssets { -+ internal static let id: Swift.String = "uploadAssets" -+ internal struct Input: Sendable, Hashable { ... } -+ internal enum Output: Sendable, Hashable { ... } -+ } -``` - -**Review checklist:** - -- [ ] Does generated code match OpenAPI spec? -- [ ] Are types correctly generated? -- [ ] No manual edits to generated files? -- [ ] File headers intact (periphery:ignore, swift-format-ignore)? - -**3. Wrapper Code Changes (Detailed Focus)** - -```diff -# Sources/MistKit/MistKitClient.swift -+ internal func uploadAssets( -+ _ assetData: Data, -+ forRecord recordName: String -+ ) async throws -> UploadAssetsResponse { -+ let response = try await client.uploadAssets(...) -+ ... -+ } ``` -**Review checklist:** - -- [ ] Proper error handling? -- [ ] Follows MistKit conventions? -- [ ] Well-documented? -- [ ] Tests included? - -#### Review Comments Examples - -**Good comments:** +Common types and how they map to OpenAPI work: -```markdown -In openapi.yaml, should the `assetData` field be required? +| Type | When to use | +| --- | --- | +| `feat` | New endpoint or new schema property exposed via the wrapper | +| `fix` | Spec correction (wrong type, missing required field, …) | +| `refactor` | Spec restructuring with no functional change | +| `docs` | Documentation-only change in `openapi.yaml` | +| `chore` | Generator version bump or pure regeneration without spec changes | -The generated types look correct, but I notice the error handling -in MistKitClient.swift doesn't account for the new 413 response. -Could we add handling for that case? +Examples: -Nice work on the comprehensive test coverage for the new endpoint! ``` - -**Avoid these comments:** - -```markdown -❌ Why did you change line 523 of Types.swift? - (Generated code - should ask about OpenAPI spec instead) - -❌ Can you rename this type to something shorter? - (Generated types follow OpenAPI naming - change the spec) - -❌ This code could be more efficient - (Generated code optimization is out of scope - file upstream issue) +feat(zones): add lookupZones operation +fix(schemas): correct asset upload response shape +chore(deps): bump swift-openapi-generator to 1.10.3 in mise.toml ``` -### 5. Handling Breaking Changes - -#### Identifying Breaking Changes - -Breaking changes occur when generated types change in incompatible ways: - -**Common breaking changes:** - -1. **Required fields added to request schemas** -2. **Required fields removed from response schemas** -3. **Enum cases removed or renamed** -4. **Parameter types changed** -5. **Response status codes changed** - -#### Example: Adding a Required Field - -**Before (`openapi.yaml`):** +## Code review -```yaml -RecordQuery: - type: object - properties: - recordType: - type: string - required: - - recordType -``` +When reviewing a PR that touches `openapi.yaml`: -**After (breaking change):** +1. **Start with the spec.** Is the change correct? Are required fields actually required? Are the response status codes complete? +2. **Check that generated code matches the spec.** A regenerated `Client.swift` / `Types.swift` should follow mechanically from the spec change. If the diff looks larger than the spec change explains, suspect either an unintended spec edit or a stale generator version. +3. **Review the wrapper.** This is where reviewer effort pays off: ergonomic API shape, error mapping, conversion correctness, test coverage. -```yaml -RecordQuery: - type: object - properties: - recordType: - type: string - zoneID: - $ref: '#/components/schemas/ZoneID' - required: - - recordType - - zoneID # New required field! -``` +Avoid review comments that target generated code style — that's the generator's output, not the author's choice. If the generated shape is genuinely problematic, file an issue against `swift-openapi-generator` or change the spec. -**Impact on generated code:** +## Breaking changes -```swift -// Before -internal struct RecordQuery { - internal var recordType: String - internal var zoneID: ZoneID? // Optional +A change is "breaking" when it requires consumers of MistKit to update their code. The most common sources: - internal init(recordType: String, zoneID: ZoneID? = nil) { ... } -} +| Cause | Example | +| --- | --- | +| Required field added to a request | New mandatory `zoneID` on `RecordQuery` | +| Required field removed from a response | Wrapper code may decode-fail on responses from older deployments | +| Enum case removed or renamed | Switches in consumer code stop compiling | +| Parameter type changed | Existing call sites break | -// After (breaking!) -internal struct RecordQuery { - internal var recordType: String - internal var zoneID: ZoneID // Now required! +For MistKit-API breaking changes, prefer the `feat!` / `BREAKING CHANGE:` convention in the commit body, and document the migration in `CHANGELOG.md`. While the package is pre-1.0 (currently 1.0.0-alpha/beta), some flexibility is acceptable — but the wrapper team has been careful to flag user-visible breakage explicitly. - internal init(recordType: String, zoneID: ZoneID) { ... } - // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - // Compile error in existing code that doesn't pass zoneID -} -``` - -#### Managing Breaking Changes +If only the generated layer changes and the wrapper preserves its public shape, the change is *not* breaking for consumers — they never see the generated types. -**Option 1: Major Version Bump** +## Updating the generator ```bash -# Update version in Package.swift -# 1.2.3 → 2.0.0 - -git commit -m "feat!: add required zoneID to RecordQuery - -BREAKING CHANGE: RecordQuery now requires zoneID parameter. -Update all query calls to include zoneID. - -Migration: - .init(recordType: \"User\") -→ .init(recordType: \"User\", zoneID: .default) -" -``` - -**Option 2: Provide Default Values (if possible)** - -```yaml -# Add default in OpenAPI spec -zoneID: - $ref: '#/components/schemas/ZoneID' - default: - zoneName: "_defaultZone" -``` - -**Option 3: Migration Period with Deprecations** - -If the old field still exists: - -```swift -// Wrapper layer provides backward compatibility -@available(*, deprecated, message: "Use init(recordType:zoneID:) instead") -internal init(recordType: String) { - self.init(recordType: recordType, zoneID: .default) -} -``` - -#### Documenting Breaking Changes - -**CHANGELOG.md entry:** - -```markdown -## [2.0.0] - 2024-01-15 +# 1. Bump the pin in mise.toml +$EDITOR mise.toml +# "spm:apple/swift-openapi-generator" = "1.10.3" → e.g. "1.11.0" -### Breaking Changes +# 2. Install the new version +mise install -- **RecordQuery now requires zoneID parameter** - - **Migration**: Add zoneID to all RecordQuery initializations - - **Before**: `.init(recordType: "User")` - - **After**: `.init(recordType: "User", zoneID: .default)` - - **Reason**: CloudKit Web Services now requires explicit zone specification - -### Migration Guide - -Update all code that creates RecordQuery instances: - -\`\`\`swift -// Old code (won't compile) -let query = RecordQuery(recordType: "User") - -// New code -let query = RecordQuery( - recordType: "User", - zoneID: ZoneID(zoneName: "_defaultZone") -) -\`\`\` -``` - -### 6. Version Control Best Practices - -#### .gitignore Configuration - -```gitignore -# DO NOT ignore generated code! -# Sources/MistKit/Generated/ - -# DO ignore build artifacts -.build/ -.swiftpm/ -*.xcodeproj/ -DerivedData/ +# 3. Regenerate +./Scripts/generate-openapi.sh -# DO ignore local tools -.mint/ +# 4. Review the diff +git diff Sources/MistKitOpenAPI/ -# DO ignore sensitive files -.env -*.pem +# 5. Build + test +swift build && swift test ``` -**Important:** Generated code must be committed! - -#### Git Attributes for Generated Files +Possible outcomes: -Create `.gitattributes`: +- **No diff** — generator improvements don't affect output for our spec. Commit only `mise.toml`. +- **Formatting / comment diff** — semantic equivalence, cosmetic change. Commit both. +- **Structural diff** — the generator produces different shapes for some construct. Update the wrapper to match before committing. -```gitattributes -# Mark generated files for better GitHub diffs -Sources/MistKit/Generated/*.swift linguist-generated=true +Commit message style: -# Ensure LF line endings for scripts -*.sh text eol=lf - -# Ensure YAML formatting -*.yaml text ``` +chore(deps): bump swift-openapi-generator to 1.11.0 in mise.toml -**Benefits:** - -- GitHub collapses generated code diffs by default -- Scripts work correctly on all platforms -- Consistent YAML formatting - -### 7. CI/CD Integration - -#### GitHub Actions Workflow - -**Purpose:** Verify generated code is up-to-date - -```yaml -# .github/workflows/verify-generated-code.yml -name: Verify Generated Code - -on: - pull_request: - paths: - - 'openapi.yaml' - - 'openapi-generator-config.yaml' - - 'Sources/MistKit/Generated/**' - -jobs: - verify-generated: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.2' - - - name: Install Mint - run: | - git clone https://github.com/yonaskolb/Mint.git - cd Mint - swift run mint bootstrap - - - name: Regenerate OpenAPI code - run: ./Scripts/generate-openapi.sh - - - name: Check for differences - run: | - if ! git diff --exit-code Sources/MistKit/Generated/; then - echo "❌ Generated code is out of date!" - echo "Run: ./Scripts/generate-openapi.sh" - exit 1 - fi - echo "✅ Generated code is up to date" - - - name: Verify build - run: swift build +Regenerated Sources/MistKitOpenAPI/. Tests pass; wrapper layer +unaffected. ``` -**What this does:** +## CI verification -1. Triggers on changes to OpenAPI spec or generated code -2. Regenerates code from scratch -3. Compares regenerated code to committed code -4. Fails if they don't match -5. Verifies build succeeds - -#### Alternative: Auto-Commit Generated Code +A typical CI job to verify generated code is up to date: ```yaml -# .github/workflows/auto-regenerate.yml -name: Auto-Regenerate Generated Code - -on: - pull_request: - paths: - - 'openapi.yaml' - - 'openapi-generator-config.yaml' - -jobs: - regenerate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.2' - - - name: Install Mint - run: | - git clone https://github.com/yonaskolb/Mint.git - cd Mint - swift run mint bootstrap - - - name: Regenerate OpenAPI code - run: ./Scripts/generate-openapi.sh - - - name: Commit changes - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - if ! git diff --exit-code Sources/MistKit/Generated/; then - git add Sources/MistKit/Generated/ - git commit -m "chore: regenerate OpenAPI client code [skip ci]" - git push - fi -``` - -**Benefits:** - -- Developers don't need to manually regenerate -- Always up-to-date generated code - -**Drawbacks:** - -- Less visibility into what changed -- Potential for surprise commits - -### 8. Updating swift-openapi-generator Version - -#### When to Update - -- 🆕 New swift-openapi-generator release with desired features -- 🐛 Bug fixes in code generation -- 🔒 Security updates - -#### Update Process - -**Step 1: Update Mintfile** - -```diff -# Mintfile - swiftlang/swift-format@601.0.0 - realm/SwiftLint@0.59.1 - peripheryapp/periphery@3.2.0 -- apple/swift-openapi-generator@1.10.0 -+ apple/swift-openapi-generator@1.11.0 -``` - -**Step 2: Clear Mint Cache** - -```bash -# Remove old version -rm -rf .mint/ -``` - -**Step 3: Bootstrap New Version** - -```bash -mint bootstrap -m Mintfile -``` - -**Step 4: Regenerate Code** - -```bash -./Scripts/generate-openapi.sh -``` - -**Step 5: Review Differences** - -```bash -git diff Sources/MistKit/Generated/ -``` - -**Possible outcomes:** - -- ✅ **No changes**: Generator improvements don't affect output -- ⚠️ **Formatting changes**: Code reformatted but semantically identical -- ⚠️ **New features**: Additional generated code (new helper methods, etc.) -- 🚨 **Breaking changes**: Generated code structure changed - -**Step 6: Test Thoroughly** - -```bash -# Clean build -swift package clean -swift build - -# Run all tests -swift test +- name: Setup tools + run: | + curl https://mise.run | sh + eval "$(~/.local/bin/mise activate bash)" + mise install -# Integration tests -swift test --filter IntegrationTests -``` - -**Step 7: Commit** - -```bash -git add Mintfile .mint/ Sources/MistKit/Generated/ -git commit -m "chore: update swift-openapi-generator to 1.11.0 +- name: Regenerate + run: ./Scripts/generate-openapi.sh -- Updated Mintfile dependency -- Regenerated client code with new generator version -- Verified all tests pass +- name: Fail if generated code drifts from spec + run: | + if ! git diff --exit-code Sources/MistKitOpenAPI/; then + echo "::error::Generated code is out of date. Run ./Scripts/generate-openapi.sh and commit." + exit 1 + fi -Generator changelog: https://github.com/apple/swift-openapi-generator/releases/tag/1.11.0 -" +- name: Build + test + run: swift build && swift test ``` -### 9. Troubleshooting Common Issues +This catches the "edited `openapi.yaml`, forgot to commit the regenerated files" mistake before it lands. -#### Issue: Generated Code Out of Sync +## Troubleshooting -**Symptoms:** +### Generated code refers to a symbol that doesn't exist -- Build errors referencing missing types -- Test failures with type mismatches -- IDE autocomplete shows wrong types - -**Solution:** +The committed files under `Sources/MistKitOpenAPI/` were produced from an earlier `openapi.yaml`. Regenerate: ```bash -# 1. Clean everything -swift package clean -rm -rf .build/ - -# 2. Regenerate from scratch ./Scripts/generate-openapi.sh - -# 3. Rebuild swift build ``` -#### Issue: Merge Conflicts in Generated Code - -**Symptoms:** - -``` -<<<<<<< HEAD -internal struct User { ... } -======= -internal struct User { ... } ->>>>>>> feature-branch -``` - -**Solution:** - -```bash -# 1. Accept either version (doesn't matter which) -git checkout --theirs Sources/MistKit/Generated/ - -# 2. Merge the OpenAPI specs carefully -git merge-tool openapi.yaml - -# 3. Regenerate from merged spec -./Scripts/generate-openapi.sh - -# 4. Stage the correctly generated code -git add Sources/MistKit/Generated/ -``` - -**Never manually resolve conflicts in generated files!** - -#### Issue: CI Fails with "Generated Code Out of Date" +### Generator version mismatch -**Symptoms:** - -``` -❌ Generated code is out of date! -Run: ./Scripts/generate-openapi.sh -``` - -**Solution:** +`swift-openapi-generator --version` doesn't match the pin in `mise.toml`. Re-install: ```bash -# Regenerate locally -./Scripts/generate-openapi.sh - -# Verify differences -git status - -# Commit updated generated code -git add Sources/MistKit/Generated/ -git commit -m "chore: update generated code to match OpenAPI spec" -git push +mise install +mise exec -- swift-openapi-generator --version ``` -#### Issue: Generator Version Mismatch +### Merge conflict in generated files -**Symptoms:** - -``` -Error: swift-openapi-generator version mismatch -Expected: 1.10.0 -Found: 1.9.0 -``` - -**Solution:** +Don't resolve by hand. Take one side arbitrarily, then regenerate from the merged `openapi.yaml`: ```bash -# Clear Mint cache -rm -rf .mint/ - -# Reinstall correct version -mint bootstrap -m Mintfile - -# Verify version -mint run swift-openapi-generator --version -``` - -## Best Practices Summary - -### DO ✅ - -- ✅ **Commit generated code** to version control -- ✅ **Regenerate after every OpenAPI spec change** -- ✅ **Review OpenAPI spec changes carefully** in pull requests -- ✅ **Run tests after regeneration** to catch breaking changes -- ✅ **Document breaking changes** in CHANGELOG -- ✅ **Use CI/CD to verify** generated code is up-to-date -- ✅ **Keep generator version** in sync across team (via Mintfile) -- ✅ **Clean build after regeneration** to avoid cached issues - -### DON'T ❌ - -- ❌ **Never manually edit generated files** (changes will be overwritten) -- ❌ **Don't ignore generated code** in .gitignore -- ❌ **Don't merge conflicts** in generated files manually -- ❌ **Don't forget to regenerate** after OpenAPI changes -- ❌ **Don't commit only spec without generated code** -- ❌ **Don't skip testing** after regeneration -- ❌ **Don't use different generator versions** on different machines - -## Real-World Example: Adding a New Endpoint - -Let's walk through a complete example of adding the `uploadAssets` endpoint: +# Accept either side of the generated diff (doesn't matter which) +git checkout --theirs Sources/MistKitOpenAPI/ -### 1. Update OpenAPI Spec +# Resolve the openapi.yaml conflict normally +$EDITOR openapi.yaml -```yaml -# openapi.yaml -paths: - /database/{version}/{container}/{environment}/{database}/assets/upload: - post: - summary: Upload Assets - description: Upload binary assets to CloudKit - operationId: uploadAssets - parameters: - - $ref: '#/components/parameters/version' - - $ref: '#/components/parameters/container' - - $ref: '#/components/parameters/environment' - - $ref: '#/components/parameters/database' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AssetUploadRequest' - responses: - '200': - description: Upload successful - content: - application/json: - schema: - $ref: '#/components/schemas/AssetUploadResponse' -``` - -### 2. Regenerate - -```bash +# Regenerate from the merged spec ./Scripts/generate-openapi.sh -``` -### 3. Verify Generated Code - -```swift -// Sources/MistKit/Generated/Types.swift (auto-generated) -internal enum Operations { - // ... existing operations - - internal enum uploadAssets { - internal static let id: Swift.String = "uploadAssets" - - internal struct Input: Sendable, Hashable { - internal struct Path: Sendable, Hashable { - internal var version: String - internal var container: String - internal var environment: environment - internal var database: database - } - internal var path: Path - internal var body: Body - } - - internal enum Output: Sendable, Hashable { - case ok(Ok) - // ... error cases - } - } -} +# Stage the now-correct generated files +git add openapi.yaml Sources/MistKitOpenAPI/ ``` -### 4. Add Wrapper Method - -```swift -// Sources/MistKit/MistKitClient.swift -extension MistKitClient { - /// Upload an asset to CloudKit - /// - /// - Parameters: - /// - data: Asset data to upload - /// - recordName: Associated record name - /// - Returns: Upload response with asset URL - /// - Throws: CloudKitError if upload fails - internal func uploadAsset( - _ data: Data, - forRecord recordName: String - ) async throws -> AssetUploadResponse { - let request = AssetUploadRequest( - assetData: data, - recordName: recordName - ) - - let response = try await client.uploadAssets( - path: .init( - version: "1", - container: configuration.container, - environment: configuration.environment, - database: configuration.database - ), - body: .json(request) - ) - - switch response { - case .ok(let okResponse): - return try okResponse.body.json - - case .badRequest(let error): - throw try CloudKitError(from: error.body.json) - - // ... handle other error cases - } - } -} -``` - -### 5. Add Tests - -```swift -// Tests/MistKitTests/AssetUploadTests.swift -import Testing -@testable import MistKit - -struct AssetUploadTests { - @Test func uploadAssetSuccess() async throws { - let mockTransport = MockTransport( - returning: .ok(AssetUploadResponse(assetURL: "https://...")) - ) - - let client = try MistKitClient( - configuration: testConfiguration, - transport: mockTransport - ) - - let assetData = Data("test asset".utf8) - let response = try await client.uploadAsset( - assetData, - forRecord: "testRecord" - ) - - #expect(response.assetURL != nil) - } -} -``` +### Wrapper test fails after regeneration -### 6. Commit +A schema change rippled into the wrapper's conversion layer. Look at: -```bash -git add openapi.yaml Sources/MistKit/Generated/ \ - Sources/MistKit/MistKitClient.swift \ - Tests/MistKitTests/AssetUploadTests.swift +- `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` — response → domain +- `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` — domain → request +- `Sources/MistKit/CloudKitService/CloudKitResponseProcessor*.swift` plus `Sources/MistKit/OpenAPI/Operations/Operations.*.Output.swift` — generated error → ``CloudKitError`` mapping +- `Sources/MistKit/Models/` — domain models that mirror schema fields -git commit -m "feat(assets): add uploadAssets endpoint +Fix the conversion, re-run `swift test`. -- Added /assets/upload endpoint to OpenAPI spec -- Regenerated client code -- Added MistKitClient.uploadAsset() wrapper method -- Added comprehensive tests +## What never to do -Closes #123 - -Generated with [Claude Code](https://claude.com/claude-code) - -Co-Authored-By: Claude " -``` +- **Don't edit `Sources/MistKitOpenAPI/` by hand.** Any change is silently lost the next time someone regenerates. +- **Don't commit `openapi.yaml` without the matching regenerated files.** The next CI run (and the next contributor) will surface drift. +- **Don't `--no-verify` past pre-commit hooks** to bypass linting on regenerated code. The `additionalFileComments` in `openapi-generator-config.yaml` emit `swift-format-ignore-file` and `periphery:ignore:all` so the linters already skip these files; if something complains, investigate before bypassing. +- **Don't ignore drift warnings in CI.** They almost always mean either an upstream generator update or someone forgot to commit a regeneration. ## See Also - - -- [Git Best Practices](https://git-scm.com/book/en/v2) -- [Semantic Versioning](https://semver.org/) -- [swift-openapi-generator Releases](https://github.com/apple/swift-openapi-generator/releases) +- +- [swift-openapi-generator releases](https://github.com/apple/swift-openapi-generator/releases) +- [mise documentation](https://mise.jdx.dev) +- [Conventional Commits](https://www.conventionalcommits.org/) diff --git a/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md b/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md index 1ff4115e..08e2b7bf 100644 --- a/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md +++ b/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md @@ -1,185 +1,67 @@ # OpenAPI Code Generation Setup -A comprehensive guide to the swift-openapi-generator integration and code generation workflow in MistKit. +How MistKit turns `openapi.yaml` into a type-safe Swift client at development time, and why that pipeline is set up the way it is. ## Overview -MistKit uses [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) to automatically generate type-safe Swift client code from the CloudKit Web Services OpenAPI specification. This approach ensures that the API client stays in sync with the OpenAPI schema while providing compile-time safety and excellent tooling support. +MistKit ships a hand-written wrapper layer on top of code generated from Apple's CloudKit Web Services OpenAPI specification by [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator). The generator runs at development time — not at consumer build time — so library users get a working package without having to install any generation tooling. -### Why Code Generation? +This article documents the toolchain (mise + the generator), the configuration file, and the request/response asymmetry that drives MistKit's custom type setup. -- **Type Safety**: Compile-time verification of API requests and responses -- **Maintainability**: Single source of truth (OpenAPI spec) for API definition -- **Documentation**: API structure documented directly in the OpenAPI spec -- **Consistency**: Automated generation eliminates manual coding errors -- **Updates**: Easy updates when CloudKit API changes +## Why generate code at all -## Architecture Overview +Generating from the OpenAPI spec gives: -``` -openapi.yaml (OpenAPI Spec) - ↓ -swift-openapi-generator - ↓ -Generated Swift Code (10,476 lines) - ├─ Client.swift (3,268 lines) - │ ├─ APIProtocol (interface) - │ ├─ Client (implementation) - │ └─ Operations namespaces - └─ Types.swift (7,208 lines) - ├─ Components.Schemas - ├─ Request/Response types - └─ Servers enum - ↓ -MistKit Wrapper Layer - ├─ MistKitClient.swift - ├─ AuthenticationMiddleware.swift - └─ CustomFieldValue.swift -``` - -## Installation and Setup - -### Prerequisites +- **A single source of truth.** The schema is `openapi.yaml`; the Swift types track it. +- **Compile-time safety.** Every request path, parameter, header, and response status is typed. +- **Free Codable.** Request and response bodies decode without hand-written model definitions. +- **A cheap-to-rerun pipeline.** When CloudKit's API changes, regenerating is one command. -- **Swift 6.1+** (MistKit uses Swift 6.2 with experimental features) -- **Mint** package manager for managing command-line tools -- **macOS 10.15+** or **Linux** (Ubuntu 18.04+) +What the wrapper layer adds on top — typed records, async iteration, structured errors, three auth schemes — is described in . -### Tool Versions - -MistKit uses the following versions (defined in `Mintfile`): +## Architecture ``` -swift-openapi-generator@1.10.0 -swift-format@601.0.0 -SwiftLint@0.59.1 -periphery@3.2.0 +openapi.yaml + │ + ▼ +swift-openapi-generator (provisioned by mise) + │ + ├── Sources/MistKitOpenAPI/Client.swift (~3,600 lines, committed) + └── Sources/MistKitOpenAPI/Types.swift (~8,600 lines, committed) + │ + ▼ +Hand-written wrapper (Sources/MistKit/, committed) + │ + ├── CloudKitService + extensions + ├── Authenticator family + AuthenticationMiddleware + ├── FieldValue / RecordInfo / QueryFilter / … + └── FieldValueRequest/Response conversions ``` -### Installing Mint +## Toolchain: mise -**On macOS (via Homebrew):** -```bash -brew install mint -``` +MistKit pins build-time tools in `mise.toml`: -**On Linux:** -```bash -git clone https://github.com/yonaskolb/Mint.git -cd Mint -swift run mint bootstrap +```toml +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4" +"spm:apple/swift-openapi-generator" = "1.10.3" ``` -### Installing swift-openapi-generator - -The project uses Mint to manage swift-openapi-generator, so no manual installation is needed. The `Scripts/generate-openapi.sh` script automatically bootstraps all required tools: +Tools are run through `mise exec` to keep the project pin authoritative regardless of what's on `$PATH`: ```bash -./Scripts/generate-openapi.sh +mise exec -- swift-format -i -r Sources/ Tests/ +mise exec -- swiftlint --fix +mise exec -- swift-openapi-generator --version ``` -This will: -1. Install Mint (if not present) -2. Bootstrap tools from `Mintfile` to `.mint` directory -3. Run swift-openapi-generator with the correct configuration -4. Generate Swift code to `Sources/MistKit/Generated/` - -## Configuration Files - -### openapi-generator-config.yaml +`./Scripts/generate-openapi.sh` puts mise's `$PATH` shims in front of the user's shell, then calls `swift-openapi-generator generate` directly. There is no Mintfile; references in older documentation to `mint`/`Mintfile` are out of date. -The configuration file controls how swift-openapi-generator produces Swift code: - -```yaml -generate: - - types # Generate data types (schemas, enums, structs) - - client # Generate API client code - -accessModifier: internal # All generated code uses 'internal' access - -typeOverrides: - schemas: - FieldValue: CustomFieldValue # Override FieldValue with custom type - -additionalFileComments: - - periphery:ignore:all # Ignore in dead code analysis - - swift-format-ignore-file # Skip auto-formatting -``` - -#### Configuration Options Explained - -**`generate`**: Controls what code is generated -- `types`: Generates all schema types, request/response models -- `client`: Generates the API client protocol and implementation -- Other options: `server` (not used in MistKit as we're building a client) - -**`accessModifier`**: Sets visibility for generated code -- `internal`: Code is accessible within the MistKit module but not to consumers (default for libraries) -- `public`: Would expose generated code to library users (not recommended) -- `package`: Swift 6+ package-level access - -**`typeOverrides`**: Custom type mappings -- Used to replace generated types with custom implementations -- MistKit overrides `FieldValue` to provide custom CloudKit field value handling -- Allows integration with hand-written wrapper types - -**`additionalFileComments`**: File-level pragmas -- `periphery:ignore:all`: Prevents false positives in dead code detection (generated code may have unused methods) -- `swift-format-ignore-file`: Preserves generated code formatting exactly as produced - -### Package.swift Integration - -MistKit uses swift-openapi-runtime dependencies but **does not use the build plugin**: - -```swift -dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), - .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"), -] -``` - -**Why no build plugin?** - -The build plugin approach can cause friction for library consumers because: -1. It requires consumers to have swift-openapi-generator installed -2. Build times increase for every consumer -3. Generated code appears in build artifacts -4. Harder to debug and inspect generated code - -Instead, MistKit uses a **pre-generation approach**: -- Code is generated during development -- Generated files are committed to version control -- Consumers get pre-generated code without needing the generator -- Faster builds and better IDE support - -### Swift Settings - -MistKit leverages Swift 6.2's cutting-edge features (defined in `Package.swift`): - -```swift -let swiftSettings: [SwiftSetting] = [ - // Upcoming Features - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault"), - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features - .enableExperimentalFeature("IsolatedAny"), - .enableExperimentalFeature("SendingArgsAndResults"), - - // Strict Concurrency - .unsafeFlags(["-strict-concurrency=complete"]) -] -``` - -These settings ensure: -- Complete Swift 6 concurrency safety -- Future-proof code with upcoming Swift features -- Type-safe async/await throughout - -## Generation Script: Scripts/generate-openapi.sh - -The shell script orchestrates the code generation process: +## Generation script ```bash #!/bin/bash @@ -187,357 +69,153 @@ set -e echo "🔄 Generating OpenAPI code..." -# Detect OS and configure Mint paths -if [ "$(uname)" = "Darwin" ]; then - DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" -elif [ "$(uname)" = "Linux" ]; then - DEFAULT_MINT_PATH="/usr/local/bin/mint" +SCRIPT_DIR=$(dirname "$(readlink -f "$0")") +PACKAGE_DIR="${SCRIPT_DIR}/.." + +# Put mise-managed tools on PATH +if command -v mise >/dev/null 2>&1; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi -MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} -export MINT_PATH="$PACKAGE_DIR/.mint" +pushd $PACKAGE_DIR -# Bootstrap tools from Mintfile -$MINT_CMD bootstrap -m Mintfile +swift-openapi-generator generate \ + --output-directory Sources/MistKit/Generated \ + --config openapi-generator-config.yaml \ + openapi.yaml -# Run generator -$MINT_CMD run swift-openapi-generator generate \ - --output-directory Sources/MistKit/Generated \ - --config openapi-generator-config.yaml \ - openapi.yaml +popd echo "✅ OpenAPI code generation complete!" ``` -### Script Features - -- **Cross-platform**: Supports both macOS and Linux -- **Environment variable support**: Can override Mint path via `MINT_CMD` -- **Local tool installation**: Installs to `.mint` directory to avoid global dependencies -- **Error handling**: Exits immediately on failure (`set -e`) -- **Clear feedback**: Progress messages for user awareness - -### Running the Script +Run it whenever `openapi.yaml` or `openapi-generator-config.yaml` changes: ```bash -# From project root ./Scripts/generate-openapi.sh - -# With custom Mint location -MINT_CMD=/custom/path/mint ./Scripts/generate-openapi.sh - -# Make executable if needed -chmod +x Scripts/generate-openapi.sh ``` -## Generated Code Structure +## Configuration -### File Organization +`openapi-generator-config.yaml`: -``` -Sources/MistKit/Generated/ -├── Client.swift (3,268 lines) -└── Types.swift (7,208 lines) +```yaml +generate: + - types + - client +accessModifier: internal +additionalFileComments: + - periphery:ignore:all + - swift-format-ignore-file ``` -Both files include header comments: -```swift -// Generated by swift-openapi-generator, do not modify. -// periphery:ignore:all -// swift-format-ignore-file -``` +| Key | Effect | +| --- | --- | +| `generate` | Emit `Types.swift` (schemas) and `Client.swift` (operations + transport plumbing). No server stubs — MistKit is a client. | +| `accessModifier: internal` | Generated symbols are module-internal; the public surface is the hand-written wrapper. | +| `additionalFileComments` | Inserts `periphery:ignore:all` so the dead-code linter skips the file, and `swift-format-ignore-file` so the formatter leaves it alone. | -### Client.swift Contents +There is intentionally no `typeOverrides` block. The asymmetry between request and response field values is handled at the schema level instead — see "Request/response asymmetry" below. -**1. APIProtocol** - Protocol defining all API operations: +## Package.swift integration -```swift -internal protocol APIProtocol: Sendable { - func queryRecords(_ input: Operations.queryRecords.Input) async throws - -> Operations.queryRecords.Output - func modifyRecords(_ input: Operations.modifyRecords.Input) async throws - -> Operations.modifyRecords.Output - // ... 13 more operations -} -``` +Generated code is referenced as ordinary source files in the `MistKit` target. The generator is **not** used as a SwiftPM build plugin. Library consumers don't need mise or `swift-openapi-generator`; they just compile the committed sources. -**2. Client Struct** - Implementation of APIProtocol: +The runtime dependencies pulled in by the generated client: ```swift -internal struct Client: APIProtocol { - private let client: UniversalClient - - internal init( - serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = [] - ) -} +.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), +.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"), ``` -**3. Convenience Extensions** - Overloads for easier method calls: +Plus the standard MistKit dependencies: `swift-http-types`, `swift-crypto`, `swift-log`, `swift-async-algorithms`. -```swift -extension APIProtocol { - internal func queryRecords( - path: Operations.queryRecords.Input.Path, - headers: Operations.queryRecords.Input.Headers = .init(), - body: Operations.queryRecords.Input.Body - ) async throws -> Operations.queryRecords.Output -} -``` +## Swift language settings -**4. Servers Enum** - Server URL definitions: +MistKit declares `swift-tools-version: 6.1` and enables the Swift 6.2 upcoming features that are stable for production use: ```swift -internal enum Servers { - internal enum Server1 { - internal static func url() throws -> Foundation.URL { - try Foundation.URL( - validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", - variables: [] - ) - } - } -} +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("ExistentialAny"), // SE-0335 + .enableUpcomingFeature("InternalImportsByDefault"), // SE-0409 + .enableUpcomingFeature("MemberImportVisibility"), // SE-0444 (Swift 6.1+) + .enableUpcomingFeature("FullTypedThrows"), // SE-0413 + // … plus experimental features stable enough for production use +] ``` -### Types.swift Contents +`InternalImportsByDefault` is the reason every import in MistKit has an explicit access modifier (`internal import Foundation`, `public import OpenAPIRuntime`, …). Generated code is compiled with the same settings. -**1. Components.Schemas** - Data models from OpenAPI schemas: +## Request/response asymmetry -```swift -internal enum Components { - internal enum Schemas { - internal struct ZoneID: Codable, Hashable, Sendable { - internal var zoneName: Swift.String? - internal var ownerName: Swift.String? - } - - internal struct Filter: Codable, Hashable, Sendable { - internal enum comparatorPayload: String, Codable, Sendable { - case EQUALS = "EQUALS" - case NOT_EQUALS = "NOT_EQUALS" - // ... 14 more cases - } - internal var comparator: comparatorPayload? - internal var fieldName: Swift.String? - internal var fieldValue: CustomFieldValue? - } - } -} -``` +The CloudKit API treats field values differently in requests and responses: -**2. Operations Namespace** - Request/response types for each operation: +- **Request bodies** omit the `type` field; CloudKit infers the type from the value's structure. +- **Response bodies** sometimes include the `type` field explicitly. -```swift -internal enum Operations { - internal enum queryRecords { - internal static let id: Swift.String = "queryRecords" - - internal struct Input: Sendable { - internal struct Path: Sendable { - internal var version: Swift.String - internal var container: Swift.String - internal var environment: Swift.String - internal var database: Swift.String - } - internal var path: Operations.queryRecords.Input.Path - internal var headers: Operations.queryRecords.Input.Headers - internal var body: Operations.queryRecords.Input.Body - } - - internal enum Output: Sendable { - internal struct Ok: Sendable { - internal var body: Body - } - case ok(Ok) - case badRequest(BadRequest) - // ... more response cases - } - } -} -``` +`openapi.yaml` reflects this with two schemas (around lines 867–920): -### Key Features of Generated Code +| Schema | Used in | Has `type` field | +| --- | --- | --- | +| `FieldValueRequest` | `RecordRequest` | No | +| `FieldValueResponse` | `RecordResponse` | Optional | -1. **All types are Sendable**: Full Swift 6 concurrency compliance -2. **Async/await throughout**: Modern Swift concurrency patterns -3. **Type-safe enums for responses**: Each HTTP status code is a distinct case -4. **Nested namespacing**: Clean organization preventing naming conflicts -5. **Codable conformance**: Automatic JSON encoding/decoding -6. **Documentation comments**: Remark annotations with OpenAPI paths +That asymmetry flows through code generation: -## Integration with MistKit Wrapper Layer +- `Components.Schemas.FieldValueRequest` +- `Components.Schemas.FieldValueResponse` +- `Components.Schemas.RecordRequest` +- `Components.Schemas.RecordResponse` -MistKit wraps the generated client to provide: +The compiler refuses to slot a response value into a request, and vice versa. Conversions to and from the single domain ``FieldValue`` enum live in: -### Custom Type Mappings +- `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` — domain → `FieldValueRequest`. +- `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` — `FieldValueResponse` → domain. -**CustomFieldValue** (overrides generated `FieldValue`): +## Files produced -```swift -// Custom implementation for CloudKit field values -internal struct CustomFieldValue: Codable, Hashable, Sendable { - // Custom logic for CloudKit-specific field types -} ``` - -Located in: `Sources/MistKit/CustomFieldValue.swift` - -### Authentication Middleware - -**AuthenticationMiddleware**: -- Adds CloudKit authentication headers/query parameters -- Supports API Token, Web Auth, and Server-to-Server auth -- Implemented as OpenAPIRuntime middleware - -Located in: `Sources/MistKit/AuthenticationMiddleware.swift` - -### MistKitClient Wrapper - -**MistKitClient**: -- High-level API wrapping generated `Client` -- Environment and database configuration -- Middleware injection (auth, logging, etc.) -- Convenience methods for common operations - -Located in: `Sources/MistKit/MistKitClient.swift` - -## Swift Language Features - -### Conditional Compilation for Linux - -Generated code handles platform differences: - -```swift -#if os(Linux) -@preconcurrency import struct Foundation.URL -@preconcurrency import struct Foundation.Data -#else -import struct Foundation.URL -import struct Foundation.Data -#endif +Sources/MistKitOpenAPI/ +├── Client.swift (~3,600 lines, committed) +└── Types.swift (~8,600 lines, committed) ``` -### SPI (System Programming Interface) Imports +Both files lead with: ```swift +// Generated by swift-openapi-generator, do not modify. +// periphery:ignore:all +// swift-format-ignore-file @_spi(Generated) import OpenAPIRuntime ``` -This imports internal OpenAPIRuntime APIs needed for generation but not exposed in the public API. +The anatomy of these files — `APIProtocol`, `Components.Schemas.*`, `Operations.*`, `Servers.Server1` — is covered in detail in . -### Type Safety Benefits +## Version control -**Before (manual HTTP client):** -```swift -// Easy to make mistakes - typos, wrong types, missing fields -let json = [ - "recordType": "User", - "fields": ["name": ["value": name]] // Nested dictionaries, no type checking -] -let data = try JSONSerialization.data(withJSONObject: json) -``` - -**After (generated client):** -```swift -// Compile-time safety - impossible to send invalid requests -let response = try await client.queryRecords( - path: .init( - version: "1", - container: containerID, - environment: "production", - database: "public" - ), - body: .json(.init( - query: .init(recordType: "User") - )) -) -``` - -## Troubleshooting - -### Common Issues - -**Problem: "swift-openapi-generator not found"** - -Solution: -```bash -# Bootstrap Mint tools -mint bootstrap -m Mintfile - -# Or install directly -mint install apple/swift-openapi-generator@1.10.0 -``` +`Sources/MistKitOpenAPI/` is **committed**. Library consumers get a working package without installing mise or `swift-openapi-generator`. Two consequences: -**Problem: "Generated code doesn't compile"** +1. Pull requests touching `openapi.yaml` should also include the regenerated `Client.swift` / `Types.swift` so reviewers see the API change. +2. CI verifies that committed generated code matches what the current `openapi.yaml` would produce (re-run the generator, diff the output) — drift fails the build. -Solution: -1. Ensure Swift 6.1+ is installed: `swift --version` -2. Check Package.swift dependencies are resolved: `swift package resolve` -3. Regenerate code: `./Scripts/generate-openapi.sh` -4. Clean build folder: `swift package clean` +`./Scripts/generate-openapi.sh` is idempotent — run it after editing `openapi.yaml` or bumping the generator version in `mise.toml`, then commit both the spec change and the regenerated files in the same commit. -**Problem: "Type 'FieldValue' not found"** - -This is expected! The type override in configuration replaces `FieldValue` with `CustomFieldValue`. Check that: -- `CustomFieldValue.swift` exists and is properly implemented -- The override is specified in `openapi-generator-config.yaml` - -**Problem: "Build plugin errors"** - -MistKit doesn't use the build plugin. If you see plugin-related errors: -- Ensure you're not adding the plugin to Package.swift -- Generated code should be pre-committed to the repository -- Run generation script manually when updating OpenAPI spec - -## Best Practices - -### When to Regenerate Code - -Regenerate generated code when: -- ✅ OpenAPI specification (`openapi.yaml`) changes -- ✅ Configuration (`openapi-generator-config.yaml`) changes -- ✅ Updating swift-openapi-generator version in Mintfile -- ❌ **NOT** on every build (use pre-generated approach) - -### Version Control - -**Always commit generated code:** -```bash -git add Sources/MistKit/Generated/ -git commit -m "Update generated OpenAPI client code" -``` - -This ensures: -- Reviewable changes in pull requests -- No generation required for library consumers -- Faster CI/CD pipelines -- Consistent builds across environments - -### Code Review Guidelines - -When reviewing generated code changes: -1. Verify the OpenAPI spec change is intentional -2. Check that type safety is maintained -3. Ensure backward compatibility (or document breaking changes) -4. Review custom overrides still align with generated types +## Troubleshooting -### Testing Generated Code +| Symptom | Cause | Fix | +| --- | --- | --- | +| `swift-openapi-generator: command not found` | mise tools not on `$PATH` | `mise install` then `eval "$(mise env -s bash)"` (or use `./Scripts/generate-openapi.sh`, which does this for you) | +| Generated code doesn't compile | Wrapper extensions reference a renamed/removed symbol | Re-run the generator; then update the affected extension in `Sources/MistKit/CloudKitService/` or `Sources/MistKit/OpenAPI/Components/` | +| `Sources/MistKitOpenAPI/` is unexpectedly empty | Accidental deletion or merge issue | `./Scripts/generate-openapi.sh`, then commit the result | +| Linter complains about generated files | The header comments were stripped | Regenerate; do not hand-edit. `additionalFileComments` re-emits the linter pragmas | -While generated code itself isn't tested (it's auto-generated), verify: -- Integration tests with MistKit wrapper layer -- Authentication middleware works with generated client -- Custom type overrides (CustomFieldValue) serialize correctly +Never edit anything under `Sources/MistKitOpenAPI/` by hand — change `openapi.yaml` and regenerate. ## See Also -- [Swift OpenAPI Generator Documentation](https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/documentation/swift-openapi-generator) -- [swift-openapi-generator Repository](https://github.com/apple/swift-openapi-generator) +- +- +- +- [`swift-openapi-generator` documentation](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator) - [OpenAPI Specification 3.0.3](https://spec.openapis.org/oas/v3.0.3) - [CloudKit Web Services API](https://developer.apple.com/documentation/cloudkitwebservices) -- ``MistKitClient`` -- ``AuthenticationMiddleware`` -- ``CustomFieldValue`` diff --git a/Sources/MistKit/Environment.swift b/Sources/MistKit/Environment.swift deleted file mode 100644 index edf4398e..00000000 --- a/Sources/MistKit/Environment.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Environment.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// CloudKit environment types -public enum Environment: String, Sendable { - case development - case production -} diff --git a/Sources/MistKit/EnvironmentConfig.swift b/Sources/MistKit/EnvironmentConfig.swift deleted file mode 100644 index f6a54fb9..00000000 --- a/Sources/MistKit/EnvironmentConfig.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// EnvironmentConfig.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Environment configuration utilities for CloudKit -public enum EnvironmentConfig { - /// Environment variable keys - public enum Keys { - /// CloudKit API token environment variable key - public static let cloudKitAPIToken = "CLOUDKIT_API_TOKEN" - - /// CloudKit Web Auth token environment variable key - public static let cloudKitWebAuthToken = "CLOUDKIT_WEB_AUTH_TOKEN" - } - - /// CloudKit-specific environment utilities - public enum CloudKit { - /// Get a masked version of environment variables for safe logging - /// - Returns: Dictionary of masked environment values - public static func getMaskedEnvironment() -> [String: String] { - var maskedEnv: [String: String] = [:] - - // Check for CloudKit-related environment variables - let cloudKitKeys = [ - "CLOUDKIT_API_TOKEN", - "CLOUDKIT_WEB_AUTH_TOKEN", - "CLOUDKIT_CONTAINER_ID", - "CLOUDKIT_ENVIRONMENT", - "CLOUDKIT_DATABASE", - ] - - for key in cloudKitKeys { - if let value = ProcessInfo.processInfo.environment[key] { - maskedEnv[key] = value.isEmpty ? "(empty)" : "\(String(value.prefix(8)))***" - } else { - maskedEnv[key] = "(not set)" - } - } - - return maskedEnv - } - } - - /// Get an optional environment variable value - /// - Parameter key: The environment variable key - /// - Returns: The environment variable value, or nil if not set - public static func getOptional(_ key: String) -> String? { - ProcessInfo.processInfo.environment[key] - } -} diff --git a/Sources/MistKit/Extensions/HTTPRequest+QueryItems.swift b/Sources/MistKit/Extensions/HTTPRequest+QueryItems.swift new file mode 100644 index 00000000..c8bedd6b --- /dev/null +++ b/Sources/MistKit/Extensions/HTTPRequest+QueryItems.swift @@ -0,0 +1,57 @@ +// +// HTTPRequest+QueryItems.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import HTTPTypes + +extension HTTPRequest { + /// Appends the given query items to this request's path, preserving any + /// existing query string. + internal mutating func appendQueryItems(_ items: [URLQueryItem]) { + let pathString = path ?? "" + let parts = pathString.split(separator: "?", maxSplits: 1) + let cleanPath = String(parts.first ?? "") + + var components = URLComponents() + components.path = cleanPath + if parts.count > 1, let existing = URLComponents(string: "?" + String(parts[1])) { + components.queryItems = existing.queryItems ?? [] + } + + var queryItems = components.queryItems ?? [] + queryItems.append(contentsOf: items) + components.queryItems = queryItems + + if let query = components.query { + path = components.path + "?" + query + } else { + path = components.path + } + } +} diff --git a/Sources/MistKit/Extensions/Logger+Subsystem.swift b/Sources/MistKit/Extensions/Logger+Subsystem.swift new file mode 100644 index 00000000..ad01f75a --- /dev/null +++ b/Sources/MistKit/Extensions/Logger+Subsystem.swift @@ -0,0 +1,43 @@ +// +// Logger+Subsystem.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Logging + +extension Logger { + internal enum Subsystem: String { + case api = "com.brightdigit.MistKit.api" + case auth = "com.brightdigit.MistKit.auth" + case network = "com.brightdigit.MistKit.network" + case middleware = "com.brightdigit.MistKit.middleware" + } + + internal init(subsystem: Subsystem) { + self.init(label: subsystem.rawValue) + } +} diff --git a/Sources/MistKit/Extensions/NSRegularExpression+CommonPatterns.swift b/Sources/MistKit/Extensions/NSRegularExpression+CommonPatterns.swift new file mode 100644 index 00000000..2ce04d13 --- /dev/null +++ b/Sources/MistKit/Extensions/NSRegularExpression+CommonPatterns.swift @@ -0,0 +1,73 @@ +// +// NSRegularExpression+CommonPatterns.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +// MARK: - Common Regex Patterns +extension NSRegularExpression { + /// CloudKit API token pattern (64-character hex string) + private static let apiTokenPattern = "^[a-fA-F0-9]{64}$" + + /// CloudKit web auth token pattern + private static let webAuthTokenPattern = "^[A-Za-z0-9+/=_]{100,}$" + + /// CloudKit key ID pattern (64-character hex string) + private static let keyIDPattern = "^[a-fA-F0-9]{64}$" +} + +// swiftlint:disable force_try +// swift-format-ignore: NeverUseForceTry +extension NSRegularExpression { + /// Compiled regex for API token validation + internal static let apiTokenRegex: NSRegularExpression = { + try! NSRegularExpression(pattern: apiTokenPattern) + }() + + /// Compiled regex for web auth token validation + internal static let webAuthTokenRegex: NSRegularExpression = { + try! NSRegularExpression(pattern: webAuthTokenPattern) + }() + + /// Compiled regex for key ID validation + internal static let keyIDRegex: NSRegularExpression = { + try! NSRegularExpression(pattern: keyIDPattern) + }() +} +// swiftlint:enable force_try + +// MARK: - Convenience Methods +extension NSRegularExpression { + /// Convenience method to match against the entire string + /// - Parameter string: The string to search in + /// - Returns: Array of NSTextCheckingResult objects + internal func matches(in string: String) -> [NSTextCheckingResult] { + let range = NSRange(string.startIndex.. Self? { - if case .string(let value) = fieldValue { - return Self(value: .StringValue(value)) - } - if case .int64(let value) = fieldValue { - return Self(value: .Int64Value(Int64(value))) - } - if case .double(let value) = fieldValue { - return Self(value: .DoubleValue(value)) - } - if case .bytes(let value) = fieldValue { - return Self(value: .BytesValue(value)) - } - if case .date(let value) = fieldValue { - return Self(value: .DateValue(value.timeIntervalSince1970 * 1_000)) - } - return nil - } - - private static func makeComplexRequest(from fieldValue: FieldValue) -> Self { - switch fieldValue { - case .location(let location): - return Self(location: location) - case .reference(let reference): - return Self(reference: reference) - case .asset(let asset): - return Self(asset: asset) - case .list(let list): - return Self(list: list) - default: - return Self(value: .ListValue([])) - } - } -} diff --git a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.Filter+MistKit.swift b/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.Filter+MistKit.swift deleted file mode 100644 index e1b99662..00000000 --- a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.Filter+MistKit.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Components.Schemas.Filter+MistKit.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Extension to convert MistKit QueryFilter to OpenAPI Components.Schemas.Filter -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension Components.Schemas.Filter { - /// Initialize from MistKit QueryFilter - internal init(from queryFilter: QueryFilter) { - self = queryFilter.filter - } -} diff --git a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.ListValuePayload+MistKit.swift b/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.ListValuePayload+MistKit.swift deleted file mode 100644 index e18c30f5..00000000 --- a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.ListValuePayload+MistKit.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// Components.Schemas.ListValuePayload+MistKit.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -extension Components.Schemas.ListValuePayload { - /// Initialize from MistKit FieldValue for list elements - internal init(from fieldValue: FieldValue) { - if let payload = Self.makeScalarPayload(from: fieldValue) { - self = payload - } else { - self = Self.makeComplexPayload(from: fieldValue) - } - } - - private static func makeScalarPayload(from fieldValue: FieldValue) -> Self? { - if case .string(let value) = fieldValue { - return .StringValue(value) - } - if case .int64(let value) = fieldValue { - return .Int64Value(Int64(value)) - } - if case .double(let value) = fieldValue { - return .DoubleValue(value) - } - if case .bytes(let value) = fieldValue { - return .BytesValue(value) - } - if case .date(let value) = fieldValue { - return .DateValue(value.timeIntervalSince1970 * 1_000) - } - return nil - } - - private static func makeComplexPayload(from fieldValue: FieldValue) -> Self { - switch fieldValue { - case .location(let location): - return .LocationValue(makeLocationValue(location)) - case .reference(let reference): - return .ReferenceValue(makeReferenceValue(reference)) - case .asset(let asset): - return .AssetValue(makeAssetValue(asset)) - case .list(let nestedList): - return .ListValue(nestedList.map { Self(from: $0) }) - default: - assertionFailure("Unexpected FieldValue case in makeComplexPayload: \(fieldValue)") - return .ListValue([]) - } - } - - private static func makeLocationValue(_ location: FieldValue.Location) - -> Components.Schemas.LocationValue - { - Components.Schemas.LocationValue( - latitude: location.latitude, - longitude: location.longitude, - horizontalAccuracy: location.horizontalAccuracy, - verticalAccuracy: location.verticalAccuracy, - altitude: location.altitude, - speed: location.speed, - course: location.course, - timestamp: location.timestamp.map { $0.timeIntervalSince1970 * 1_000 } - ) - } - - private static func makeReferenceValue(_ reference: FieldValue.Reference) - -> Components.Schemas.ReferenceValue - { - let action: Components.Schemas.ReferenceValue.actionPayload? - switch reference.action { - case .some(.deleteSelf): - action = .DELETE_SELF - case .some(.none): - action = .NONE - case nil: - action = nil - } - return Components.Schemas.ReferenceValue( - recordName: reference.recordName, - action: action - ) - } - - private static func makeAssetValue(_ asset: FieldValue.Asset) -> Components.Schemas.AssetValue { - Components.Schemas.AssetValue( - fileChecksum: asset.fileChecksum, - size: asset.size, - referenceChecksum: asset.referenceChecksum, - wrappingKey: asset.wrappingKey, - receipt: asset.receipt, - downloadURL: asset.downloadURL - ) - } -} diff --git a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.RecordOperation+MistKit.swift b/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.RecordOperation+MistKit.swift deleted file mode 100644 index ba24cb2a..00000000 --- a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.RecordOperation+MistKit.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// Components.Schemas.RecordOperation+MistKit.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Extension to convert MistKit RecordOperation to OpenAPI Components.Schemas.RecordOperation -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension Components.Schemas.RecordOperation { - /// Mapping from RecordOperation.OperationType to OpenAPI operationTypePayload - private static let operationTypeMapping: - [RecordOperation.OperationType: Components.Schemas.RecordOperation.operationTypePayload] = [ - .create: .create, - .update: .update, - .forceUpdate: .forceUpdate, - .replace: .replace, - .forceReplace: .forceReplace, - .delete: .delete, - .forceDelete: .forceDelete, - ] - - /// Initialize from MistKit RecordOperation - internal init(from recordOperation: RecordOperation) { - // Convert operation type using dictionary lookup - guard let apiOperationType = Self.operationTypeMapping[recordOperation.operationType] else { - fatalError("Unknown operation type: \(recordOperation.operationType)") - } - - // Convert fields to OpenAPI FieldValueRequest format (for requests) - let apiFields = recordOperation.fields.mapValues { - fieldValue -> Components.Schemas.FieldValueRequest in - Components.Schemas.FieldValueRequest(from: fieldValue) - } - - // Build the OpenAPI record operation - self.init( - operationType: apiOperationType, - record: .init( - recordName: recordOperation.recordName, - recordType: recordOperation.recordType, - recordChangeTag: recordOperation.recordChangeTag, - fields: .init(additionalProperties: apiFields) - ) - ) - } -} diff --git a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.Sort+MistKit.swift b/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.Sort+MistKit.swift deleted file mode 100644 index 9b85c497..00000000 --- a/Sources/MistKit/Extensions/OpenAPI/Components.Schemas.Sort+MistKit.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Components.Schemas.Sort+MistKit.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Extension to convert MistKit QuerySort to OpenAPI Components.Schemas.Sort -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension Components.Schemas.Sort { - /// Initialize from MistKit QuerySort - internal init(from querySort: QuerySort) { - self = querySort.sort - } -} diff --git a/Sources/MistKit/FieldValue.swift b/Sources/MistKit/FieldValue.swift deleted file mode 100644 index 37a8fdce..00000000 --- a/Sources/MistKit/FieldValue.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// FieldValue.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Represents a CloudKit field value as defined in the CloudKit Web Services API -public enum FieldValue: Codable, Equatable, Sendable { - case string(String) - case int64(Int) - case double(Double) - case bytes(String) // Base64-encoded string - case date(Date) // Date/time value - case location(Location) - case reference(Reference) - case asset(Asset) - case list([FieldValue]) - - /// Location dictionary as defined in CloudKit Web Services - public struct Location: Codable, Equatable, Sendable { - /// The latitude coordinate - public let latitude: Double - /// The longitude coordinate - public let longitude: Double - /// The horizontal accuracy in meters - public let horizontalAccuracy: Double? - /// The vertical accuracy in meters - public let verticalAccuracy: Double? - /// The altitude in meters - public let altitude: Double? - /// The speed in meters per second - public let speed: Double? - /// The course in degrees - public let course: Double? - /// The timestamp when location was recorded - public let timestamp: Date? - - /// Initialize a location value - public init( - latitude: Double, - longitude: Double, - horizontalAccuracy: Double? = nil, - verticalAccuracy: Double? = nil, - altitude: Double? = nil, - speed: Double? = nil, - course: Double? = nil, - timestamp: Date? = nil - ) { - self.latitude = latitude - self.longitude = longitude - self.horizontalAccuracy = horizontalAccuracy - self.verticalAccuracy = verticalAccuracy - self.altitude = altitude - self.speed = speed - self.course = course - self.timestamp = timestamp - } - } - - /// Reference dictionary as defined in CloudKit Web Services - public struct Reference: Codable, Equatable, Sendable { - /// Reference action types supported by CloudKit - public enum Action: String, Codable, Sendable { - case deleteSelf = "DELETE_SELF" - case none = "NONE" - } - - /// The record name being referenced - public let recordName: String - /// The action to take (DELETE_SELF, NONE, or nil) - public let action: Action? - - /// Initialize a reference value - public init(recordName: String, action: Action? = nil) { - self.recordName = recordName - self.action = action - } - } - - /// Asset dictionary as defined in CloudKit Web Services - public struct Asset: Codable, Equatable, Sendable { - /// The file checksum - public let fileChecksum: String? - /// The file size in bytes - public let size: Int64? - /// The reference checksum - public let referenceChecksum: String? - /// The wrapping key for encryption - public let wrappingKey: String? - /// The upload receipt - public let receipt: String? - /// The download URL - public let downloadURL: String? - - /// Initialize an asset value - public init( - fileChecksum: String? = nil, - size: Int64? = nil, - referenceChecksum: String? = nil, - wrappingKey: String? = nil, - receipt: String? = nil, - downloadURL: String? = nil - ) { - self.fileChecksum = fileChecksum - self.size = size - self.referenceChecksum = referenceChecksum - self.wrappingKey = wrappingKey - self.receipt = receipt - self.downloadURL = downloadURL - } - } -} diff --git a/Sources/MistKit/Generated/Types.swift b/Sources/MistKit/Generated/Types.swift deleted file mode 100644 index 7546e936..00000000 --- a/Sources/MistKit/Generated/Types.swift +++ /dev/null @@ -1,7783 +0,0 @@ -// Generated by swift-openapi-generator, do not modify. -// periphery:ignore:all -// swift-format-ignore-file -@_spi(Generated) import OpenAPIRuntime -#if os(Linux) -@preconcurrency import struct Foundation.URL -@preconcurrency import struct Foundation.Data -@preconcurrency import struct Foundation.Date -#else -import struct Foundation.URL -import struct Foundation.Data -import struct Foundation.Date -#endif -/// A type that performs HTTP operations defined by the OpenAPI document. -internal protocol APIProtocol: Sendable { - /// Query Records - /// - /// Fetch records using a query with filters and sorting options - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)`. - func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output - /// Modify Records - /// - /// Create, update, or delete records (supports bulk operations) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. - func modifyRecords(_ input: Operations.modifyRecords.Input) async throws -> Operations.modifyRecords.Output - /// Lookup Records - /// - /// Fetch specific records by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. - func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output - /// Fetch Record Changes - /// - /// Get all record changes relative to a sync token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. - func fetchRecordChanges(_ input: Operations.fetchRecordChanges.Input) async throws -> Operations.fetchRecordChanges.Output - /// List All Zones - /// - /// Fetch all zones in the database - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. - func listZones(_ input: Operations.listZones.Input) async throws -> Operations.listZones.Output - /// Lookup Zones - /// - /// Fetch specific zones by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. - func lookupZones(_ input: Operations.lookupZones.Input) async throws -> Operations.lookupZones.Output - /// Modify Zones - /// - /// Create or delete zones (only supported in private database) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. - func modifyZones(_ input: Operations.modifyZones.Input) async throws -> Operations.modifyZones.Output - /// Fetch Zone Changes - /// - /// Get all changed zones relative to a meta-sync token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. - func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output - /// List All Subscriptions - /// - /// Fetch all subscriptions in the database - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. - func listSubscriptions(_ input: Operations.listSubscriptions.Input) async throws -> Operations.listSubscriptions.Output - /// Lookup Subscriptions - /// - /// Fetch specific subscriptions by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. - func lookupSubscriptions(_ input: Operations.lookupSubscriptions.Input) async throws -> Operations.lookupSubscriptions.Output - /// Modify Subscriptions - /// - /// Create, update, or delete subscriptions - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. - func modifySubscriptions(_ input: Operations.modifySubscriptions.Input) async throws -> Operations.modifySubscriptions.Output - /// Get Current User - /// - /// Fetch the current authenticated user's information - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - func getCurrentUser(_ input: Operations.getCurrentUser.Input) async throws -> Operations.getCurrentUser.Output - /// Discover User Identities - /// - /// Discover all user identities based on email addresses or user record names - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. - func discoverUserIdentities(_ input: Operations.discoverUserIdentities.Input) async throws -> Operations.discoverUserIdentities.Output - /// Lookup Contacts (Deprecated) - /// - /// Fetch contacts (This endpoint is deprecated) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. - @available(*, deprecated) - func lookupContacts(_ input: Operations.lookupContacts.Input) async throws -> Operations.lookupContacts.Output - /// Request Asset Upload URLs - /// - /// Request upload URLs for asset fields. This is the first step in a two-step process: - /// 1. Request upload URLs by specifying the record type and field name - /// 2. Upload the actual binary data to the returned URL (separate HTTP request) - /// - /// Upload URLs are valid for 15 minutes. Maximum file size is 15 MB. - /// - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. - func uploadAssets(_ input: Operations.uploadAssets.Input) async throws -> Operations.uploadAssets.Output - /// Create APNs Token - /// - /// Create an Apple Push Notification service (APNs) token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. - func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output - /// Register Token - /// - /// Register a token for push notifications - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. - func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output -} - -/// Convenience overloads for operation inputs. -extension APIProtocol { - /// Query Records - /// - /// Fetch records using a query with filters and sorting options - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)`. - internal func queryRecords( - path: Operations.queryRecords.Input.Path, - headers: Operations.queryRecords.Input.Headers = .init(), - body: Operations.queryRecords.Input.Body - ) async throws -> Operations.queryRecords.Output { - try await queryRecords(Operations.queryRecords.Input( - path: path, - headers: headers, - body: body - )) - } - /// Modify Records - /// - /// Create, update, or delete records (supports bulk operations) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. - internal func modifyRecords( - path: Operations.modifyRecords.Input.Path, - headers: Operations.modifyRecords.Input.Headers = .init(), - body: Operations.modifyRecords.Input.Body - ) async throws -> Operations.modifyRecords.Output { - try await modifyRecords(Operations.modifyRecords.Input( - path: path, - headers: headers, - body: body - )) - } - /// Lookup Records - /// - /// Fetch specific records by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. - internal func lookupRecords( - path: Operations.lookupRecords.Input.Path, - headers: Operations.lookupRecords.Input.Headers = .init(), - body: Operations.lookupRecords.Input.Body - ) async throws -> Operations.lookupRecords.Output { - try await lookupRecords(Operations.lookupRecords.Input( - path: path, - headers: headers, - body: body - )) - } - /// Fetch Record Changes - /// - /// Get all record changes relative to a sync token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. - internal func fetchRecordChanges( - path: Operations.fetchRecordChanges.Input.Path, - headers: Operations.fetchRecordChanges.Input.Headers = .init(), - body: Operations.fetchRecordChanges.Input.Body - ) async throws -> Operations.fetchRecordChanges.Output { - try await fetchRecordChanges(Operations.fetchRecordChanges.Input( - path: path, - headers: headers, - body: body - )) - } - /// List All Zones - /// - /// Fetch all zones in the database - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. - internal func listZones( - path: Operations.listZones.Input.Path, - headers: Operations.listZones.Input.Headers = .init() - ) async throws -> Operations.listZones.Output { - try await listZones(Operations.listZones.Input( - path: path, - headers: headers - )) - } - /// Lookup Zones - /// - /// Fetch specific zones by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. - internal func lookupZones( - path: Operations.lookupZones.Input.Path, - headers: Operations.lookupZones.Input.Headers = .init(), - body: Operations.lookupZones.Input.Body - ) async throws -> Operations.lookupZones.Output { - try await lookupZones(Operations.lookupZones.Input( - path: path, - headers: headers, - body: body - )) - } - /// Modify Zones - /// - /// Create or delete zones (only supported in private database) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. - internal func modifyZones( - path: Operations.modifyZones.Input.Path, - headers: Operations.modifyZones.Input.Headers = .init(), - body: Operations.modifyZones.Input.Body - ) async throws -> Operations.modifyZones.Output { - try await modifyZones(Operations.modifyZones.Input( - path: path, - headers: headers, - body: body - )) - } - /// Fetch Zone Changes - /// - /// Get all changed zones relative to a meta-sync token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. - internal func fetchZoneChanges( - path: Operations.fetchZoneChanges.Input.Path, - headers: Operations.fetchZoneChanges.Input.Headers = .init(), - body: Operations.fetchZoneChanges.Input.Body - ) async throws -> Operations.fetchZoneChanges.Output { - try await fetchZoneChanges(Operations.fetchZoneChanges.Input( - path: path, - headers: headers, - body: body - )) - } - /// List All Subscriptions - /// - /// Fetch all subscriptions in the database - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. - internal func listSubscriptions( - path: Operations.listSubscriptions.Input.Path, - headers: Operations.listSubscriptions.Input.Headers = .init() - ) async throws -> Operations.listSubscriptions.Output { - try await listSubscriptions(Operations.listSubscriptions.Input( - path: path, - headers: headers - )) - } - /// Lookup Subscriptions - /// - /// Fetch specific subscriptions by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. - internal func lookupSubscriptions( - path: Operations.lookupSubscriptions.Input.Path, - headers: Operations.lookupSubscriptions.Input.Headers = .init(), - body: Operations.lookupSubscriptions.Input.Body - ) async throws -> Operations.lookupSubscriptions.Output { - try await lookupSubscriptions(Operations.lookupSubscriptions.Input( - path: path, - headers: headers, - body: body - )) - } - /// Modify Subscriptions - /// - /// Create, update, or delete subscriptions - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. - internal func modifySubscriptions( - path: Operations.modifySubscriptions.Input.Path, - headers: Operations.modifySubscriptions.Input.Headers = .init(), - body: Operations.modifySubscriptions.Input.Body - ) async throws -> Operations.modifySubscriptions.Output { - try await modifySubscriptions(Operations.modifySubscriptions.Input( - path: path, - headers: headers, - body: body - )) - } - /// Get Current User - /// - /// Fetch the current authenticated user's information - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - internal func getCurrentUser( - path: Operations.getCurrentUser.Input.Path, - headers: Operations.getCurrentUser.Input.Headers = .init() - ) async throws -> Operations.getCurrentUser.Output { - try await getCurrentUser(Operations.getCurrentUser.Input( - path: path, - headers: headers - )) - } - /// Discover User Identities - /// - /// Discover all user identities based on email addresses or user record names - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. - internal func discoverUserIdentities( - path: Operations.discoverUserIdentities.Input.Path, - headers: Operations.discoverUserIdentities.Input.Headers = .init(), - body: Operations.discoverUserIdentities.Input.Body - ) async throws -> Operations.discoverUserIdentities.Output { - try await discoverUserIdentities(Operations.discoverUserIdentities.Input( - path: path, - headers: headers, - body: body - )) - } - /// Lookup Contacts (Deprecated) - /// - /// Fetch contacts (This endpoint is deprecated) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. - @available(*, deprecated) - internal func lookupContacts( - path: Operations.lookupContacts.Input.Path, - headers: Operations.lookupContacts.Input.Headers = .init(), - body: Operations.lookupContacts.Input.Body - ) async throws -> Operations.lookupContacts.Output { - try await lookupContacts(Operations.lookupContacts.Input( - path: path, - headers: headers, - body: body - )) - } - /// Request Asset Upload URLs - /// - /// Request upload URLs for asset fields. This is the first step in a two-step process: - /// 1. Request upload URLs by specifying the record type and field name - /// 2. Upload the actual binary data to the returned URL (separate HTTP request) - /// - /// Upload URLs are valid for 15 minutes. Maximum file size is 15 MB. - /// - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. - internal func uploadAssets( - path: Operations.uploadAssets.Input.Path, - headers: Operations.uploadAssets.Input.Headers = .init(), - body: Operations.uploadAssets.Input.Body - ) async throws -> Operations.uploadAssets.Output { - try await uploadAssets(Operations.uploadAssets.Input( - path: path, - headers: headers, - body: body - )) - } - /// Create APNs Token - /// - /// Create an Apple Push Notification service (APNs) token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. - internal func createToken( - path: Operations.createToken.Input.Path, - headers: Operations.createToken.Input.Headers = .init(), - body: Operations.createToken.Input.Body - ) async throws -> Operations.createToken.Output { - try await createToken(Operations.createToken.Input( - path: path, - headers: headers, - body: body - )) - } - /// Register Token - /// - /// Register a token for push notifications - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. - internal func registerToken( - path: Operations.registerToken.Input.Path, - headers: Operations.registerToken.Input.Headers = .init(), - body: Operations.registerToken.Input.Body - ) async throws -> Operations.registerToken.Output { - try await registerToken(Operations.registerToken.Input( - path: path, - headers: headers, - body: body - )) - } -} - -/// Server URLs defined in the OpenAPI document. -internal enum Servers { - /// CloudKit Web Services API - internal enum Server1 { - /// CloudKit Web Services API - internal static func url() throws -> Foundation.URL { - try Foundation.URL( - validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", - variables: [] - ) - } - } - /// CloudKit Web Services API - @available(*, deprecated, renamed: "Servers.Server1.url") - internal static func server1() throws -> Foundation.URL { - try Foundation.URL( - validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", - variables: [] - ) - } -} - -/// Types generated from the components section of the OpenAPI document. -internal enum Components { - /// Types generated from the `#/components/schemas` section of the OpenAPI document. - internal enum Schemas { - /// - Remark: Generated from `#/components/schemas/ZoneID`. - internal struct ZoneID: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneID/zoneName`. - internal var zoneName: Swift.String? - /// - Remark: Generated from `#/components/schemas/ZoneID/ownerName`. - internal var ownerName: Swift.String? - /// Creates a new `ZoneID`. - /// - /// - Parameters: - /// - zoneName: - /// - ownerName: - internal init( - zoneName: Swift.String? = nil, - ownerName: Swift.String? = nil - ) { - self.zoneName = zoneName - self.ownerName = ownerName - } - internal enum CodingKeys: String, CodingKey { - case zoneName - case ownerName - } - } - /// - Remark: Generated from `#/components/schemas/Filter`. - internal struct Filter: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/Filter/comparator`. - internal enum comparatorPayload: String, Codable, Hashable, Sendable, CaseIterable { - case EQUALS = "EQUALS" - case NOT_EQUALS = "NOT_EQUALS" - case LESS_THAN = "LESS_THAN" - case LESS_THAN_OR_EQUALS = "LESS_THAN_OR_EQUALS" - case GREATER_THAN = "GREATER_THAN" - case GREATER_THAN_OR_EQUALS = "GREATER_THAN_OR_EQUALS" - case NEAR = "NEAR" - case CONTAINS_ALL_TOKENS = "CONTAINS_ALL_TOKENS" - case IN = "IN" - case NOT_IN = "NOT_IN" - case CONTAINS_ANY_TOKENS = "CONTAINS_ANY_TOKENS" - case LIST_CONTAINS = "LIST_CONTAINS" - case NOT_LIST_CONTAINS = "NOT_LIST_CONTAINS" - case BEGINS_WITH = "BEGINS_WITH" - case NOT_BEGINS_WITH = "NOT_BEGINS_WITH" - case LIST_MEMBER_BEGINS_WITH = "LIST_MEMBER_BEGINS_WITH" - case NOT_LIST_MEMBER_BEGINS_WITH = "NOT_LIST_MEMBER_BEGINS_WITH" - } - /// - Remark: Generated from `#/components/schemas/Filter/comparator`. - internal var comparator: Components.Schemas.Filter.comparatorPayload? - /// - Remark: Generated from `#/components/schemas/Filter/fieldName`. - internal var fieldName: Swift.String? - /// - Remark: Generated from `#/components/schemas/Filter/fieldValue`. - internal var fieldValue: Components.Schemas.FieldValueRequest? - /// Creates a new `Filter`. - /// - /// - Parameters: - /// - comparator: - /// - fieldName: - /// - fieldValue: - internal init( - comparator: Components.Schemas.Filter.comparatorPayload? = nil, - fieldName: Swift.String? = nil, - fieldValue: Components.Schemas.FieldValueRequest? = nil - ) { - self.comparator = comparator - self.fieldName = fieldName - self.fieldValue = fieldValue - } - internal enum CodingKeys: String, CodingKey { - case comparator - case fieldName - case fieldValue - } - } - /// - Remark: Generated from `#/components/schemas/Sort`. - internal struct Sort: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/Sort/fieldName`. - internal var fieldName: Swift.String? - /// - Remark: Generated from `#/components/schemas/Sort/ascending`. - internal var ascending: Swift.Bool? - /// Creates a new `Sort`. - /// - /// - Parameters: - /// - fieldName: - /// - ascending: - internal init( - fieldName: Swift.String? = nil, - ascending: Swift.Bool? = nil - ) { - self.fieldName = fieldName - self.ascending = ascending - } - internal enum CodingKeys: String, CodingKey { - case fieldName - case ascending - } - } - /// - Remark: Generated from `#/components/schemas/RecordOperation`. - internal struct RecordOperation: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/RecordOperation/operationType`. - internal enum operationTypePayload: String, Codable, Hashable, Sendable, CaseIterable { - case create = "create" - case update = "update" - case forceUpdate = "forceUpdate" - case replace = "replace" - case forceReplace = "forceReplace" - case delete = "delete" - case forceDelete = "forceDelete" - } - /// - Remark: Generated from `#/components/schemas/RecordOperation/operationType`. - internal var operationType: Components.Schemas.RecordOperation.operationTypePayload? - /// - Remark: Generated from `#/components/schemas/RecordOperation/record`. - internal var record: Components.Schemas.RecordRequest? - /// Creates a new `RecordOperation`. - /// - /// - Parameters: - /// - operationType: - /// - record: - internal init( - operationType: Components.Schemas.RecordOperation.operationTypePayload? = nil, - record: Components.Schemas.RecordRequest? = nil - ) { - self.operationType = operationType - self.record = record - } - internal enum CodingKeys: String, CodingKey { - case operationType - case record - } - } - /// Record schema for API requests (fields use FieldValueRequest) - /// - /// - Remark: Generated from `#/components/schemas/RecordRequest`. - internal struct RecordRequest: Codable, Hashable, Sendable { - /// The unique identifier for the record - /// - /// - Remark: Generated from `#/components/schemas/RecordRequest/recordName`. - internal var recordName: Swift.String? - /// The record type (schema name) - /// - /// - Remark: Generated from `#/components/schemas/RecordRequest/recordType`. - internal var recordType: Swift.String? - /// Change tag for optimistic concurrency control - /// - /// - Remark: Generated from `#/components/schemas/RecordRequest/recordChangeTag`. - internal var recordChangeTag: Swift.String? - /// Record fields with their values (no type metadata) - /// - /// - Remark: Generated from `#/components/schemas/RecordRequest/fields`. - internal struct fieldsPayload: Codable, Hashable, Sendable { - /// A container of undocumented properties. - internal var additionalProperties: [String: Components.Schemas.FieldValueRequest] - /// Creates a new `fieldsPayload`. - /// - /// - Parameters: - /// - additionalProperties: A container of undocumented properties. - internal init(additionalProperties: [String: Components.Schemas.FieldValueRequest] = .init()) { - self.additionalProperties = additionalProperties - } - internal init(from decoder: any Decoder) throws { - additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) - } - internal func encode(to encoder: any Encoder) throws { - try encoder.encodeAdditionalProperties(additionalProperties) - } - } - /// Record fields with their values (no type metadata) - /// - /// - Remark: Generated from `#/components/schemas/RecordRequest/fields`. - internal var fields: Components.Schemas.RecordRequest.fieldsPayload? - /// Creates a new `RecordRequest`. - /// - /// - Parameters: - /// - recordName: The unique identifier for the record - /// - recordType: The record type (schema name) - /// - recordChangeTag: Change tag for optimistic concurrency control - /// - fields: Record fields with their values (no type metadata) - internal init( - recordName: Swift.String? = nil, - recordType: Swift.String? = nil, - recordChangeTag: Swift.String? = nil, - fields: Components.Schemas.RecordRequest.fieldsPayload? = nil - ) { - self.recordName = recordName - self.recordType = recordType - self.recordChangeTag = recordChangeTag - self.fields = fields - } - internal enum CodingKeys: String, CodingKey { - case recordName - case recordType - case recordChangeTag - case fields - } - } - /// Record schema for API responses (fields use FieldValueResponse) - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse`. - internal struct RecordResponse: Codable, Hashable, Sendable { - /// The unique identifier for the record - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse/recordName`. - internal var recordName: Swift.String? - /// The record type (schema name) - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse/recordType`. - internal var recordType: Swift.String? - /// Change tag for optimistic concurrency control - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse/recordChangeTag`. - internal var recordChangeTag: Swift.String? - /// Record fields with their values and optional type information - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse/fields`. - internal struct fieldsPayload: Codable, Hashable, Sendable { - /// A container of undocumented properties. - internal var additionalProperties: [String: Components.Schemas.FieldValueResponse] - /// Creates a new `fieldsPayload`. - /// - /// - Parameters: - /// - additionalProperties: A container of undocumented properties. - internal init(additionalProperties: [String: Components.Schemas.FieldValueResponse] = .init()) { - self.additionalProperties = additionalProperties - } - internal init(from decoder: any Decoder) throws { - additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) - } - internal func encode(to encoder: any Encoder) throws { - try encoder.encodeAdditionalProperties(additionalProperties) - } - } - /// Record fields with their values and optional type information - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse/fields`. - internal var fields: Components.Schemas.RecordResponse.fieldsPayload? - /// - Remark: Generated from `#/components/schemas/RecordResponse/created`. - internal var created: Components.Schemas.RecordTimestamp? - /// - Remark: Generated from `#/components/schemas/RecordResponse/modified`. - internal var modified: Components.Schemas.RecordTimestamp? - /// Whether the record was deleted - /// - /// - Remark: Generated from `#/components/schemas/RecordResponse/deleted`. - internal var deleted: Swift.Bool? - /// Creates a new `RecordResponse`. - /// - /// - Parameters: - /// - recordName: The unique identifier for the record - /// - recordType: The record type (schema name) - /// - recordChangeTag: Change tag for optimistic concurrency control - /// - fields: Record fields with their values and optional type information - /// - created: - /// - modified: - /// - deleted: Whether the record was deleted - internal init( - recordName: Swift.String? = nil, - recordType: Swift.String? = nil, - recordChangeTag: Swift.String? = nil, - fields: Components.Schemas.RecordResponse.fieldsPayload? = nil, - created: Components.Schemas.RecordTimestamp? = nil, - modified: Components.Schemas.RecordTimestamp? = nil, - deleted: Swift.Bool? = nil - ) { - self.recordName = recordName - self.recordType = recordType - self.recordChangeTag = recordChangeTag - self.fields = fields - self.created = created - self.modified = modified - self.deleted = deleted - } - internal enum CodingKeys: String, CodingKey { - case recordName - case recordType - case recordChangeTag - case fields - case created - case modified - case deleted - } - } - /// A CloudKit field value for API requests. - /// The type field is optional and used for IN/NOT_IN list filters to specify the list element type. - /// - /// - /// - Remark: Generated from `#/components/schemas/FieldValueRequest`. - internal struct FieldValueRequest: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value`. - internal enum valuePayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case1`. - case StringValue(Components.Schemas.StringValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case2`. - case Int64Value(Components.Schemas.Int64Value) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case3`. - case DoubleValue(Components.Schemas.DoubleValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case4`. - case BytesValue(Components.Schemas.BytesValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case5`. - case DateValue(Components.Schemas.DateValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case6`. - case LocationValue(Components.Schemas.LocationValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case7`. - case ReferenceValue(Components.Schemas.ReferenceValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case8`. - case AssetValue(Components.Schemas.AssetValue) - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case9`. - case ListValue(Components.Schemas.ListValue) - internal init(from decoder: any Decoder) throws { - var errors: [any Error] = [] - do { - self = .StringValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .Int64Value(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .DoubleValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .BytesValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .DateValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .LocationValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .ReferenceValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .AssetValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .ListValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - throw Swift.DecodingError.failedToDecodeOneOfSchema( - type: Self.self, - codingPath: decoder.codingPath, - errors: errors - ) - } - internal func encode(to encoder: any Encoder) throws { - switch self { - case let .StringValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .Int64Value(value): - try encoder.encodeToSingleValueContainer(value) - case let .DoubleValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .BytesValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .DateValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .LocationValue(value): - try value.encode(to: encoder) - case let .ReferenceValue(value): - try value.encode(to: encoder) - case let .AssetValue(value): - try value.encode(to: encoder) - case let .ListValue(value): - try encoder.encodeToSingleValueContainer(value) - } - } - } - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value`. - internal var value: Components.Schemas.FieldValueRequest.valuePayload - /// Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). - /// - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/type`. - internal enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case STRING_LIST = "STRING_LIST" - case INT64_LIST = "INT64_LIST" - case DOUBLE_LIST = "DOUBLE_LIST" - case BYTES_LIST = "BYTES_LIST" - case TIMESTAMP_LIST = "TIMESTAMP_LIST" - case REFERENCE_LIST = "REFERENCE_LIST" - case LOCATION_LIST = "LOCATION_LIST" - case ASSET_LIST = "ASSET_LIST" - case LIST = "LIST" - } - /// Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). - /// - /// - Remark: Generated from `#/components/schemas/FieldValueRequest/type`. - internal var _type: Components.Schemas.FieldValueRequest._typePayload? - /// Creates a new `FieldValueRequest`. - /// - /// - Parameters: - /// - value: - /// - _type: Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). - internal init( - value: Components.Schemas.FieldValueRequest.valuePayload, - _type: Components.Schemas.FieldValueRequest._typePayload? = nil - ) { - self.value = value - self._type = _type - } - internal enum CodingKeys: String, CodingKey { - case value - case _type = "type" - } - } - /// A CloudKit field value from API responses. - /// May include optional type field for explicit type information. - /// - /// - /// - Remark: Generated from `#/components/schemas/FieldValueResponse`. - internal struct FieldValueResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value`. - internal enum valuePayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case1`. - case StringValue(Components.Schemas.StringValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case2`. - case Int64Value(Components.Schemas.Int64Value) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case3`. - case DoubleValue(Components.Schemas.DoubleValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case4`. - case BytesValue(Components.Schemas.BytesValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case5`. - case DateValue(Components.Schemas.DateValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case6`. - case LocationValue(Components.Schemas.LocationValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case7`. - case ReferenceValue(Components.Schemas.ReferenceValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case8`. - case AssetValue(Components.Schemas.AssetValue) - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case9`. - case ListValue(Components.Schemas.ListValue) - internal init(from decoder: any Decoder) throws { - var errors: [any Error] = [] - do { - self = .StringValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .Int64Value(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .DoubleValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .BytesValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .DateValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .LocationValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .ReferenceValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .AssetValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .ListValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - throw Swift.DecodingError.failedToDecodeOneOfSchema( - type: Self.self, - codingPath: decoder.codingPath, - errors: errors - ) - } - internal func encode(to encoder: any Encoder) throws { - switch self { - case let .StringValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .Int64Value(value): - try encoder.encodeToSingleValueContainer(value) - case let .DoubleValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .BytesValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .DateValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .LocationValue(value): - try value.encode(to: encoder) - case let .ReferenceValue(value): - try value.encode(to: encoder) - case let .AssetValue(value): - try value.encode(to: encoder) - case let .ListValue(value): - try encoder.encodeToSingleValueContainer(value) - } - } - } - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value`. - internal var value: Components.Schemas.FieldValueResponse.valuePayload - /// The CloudKit field type (optional, may be inferred from value) - /// - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/type`. - internal enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case STRING = "STRING" - case INT64 = "INT64" - case DOUBLE = "DOUBLE" - case BYTES = "BYTES" - case REFERENCE = "REFERENCE" - case ASSET = "ASSET" - case ASSETID = "ASSETID" - case LOCATION = "LOCATION" - case TIMESTAMP = "TIMESTAMP" - case LIST = "LIST" - } - /// The CloudKit field type (optional, may be inferred from value) - /// - /// - Remark: Generated from `#/components/schemas/FieldValueResponse/type`. - internal var _type: Components.Schemas.FieldValueResponse._typePayload? - /// Creates a new `FieldValueResponse`. - /// - /// - Parameters: - /// - value: - /// - _type: The CloudKit field type (optional, may be inferred from value) - internal init( - value: Components.Schemas.FieldValueResponse.valuePayload, - _type: Components.Schemas.FieldValueResponse._typePayload? = nil - ) { - self.value = value - self._type = _type - } - internal enum CodingKeys: String, CodingKey { - case value - case _type = "type" - } - } - /// A text string value - /// - /// - Remark: Generated from `#/components/schemas/StringValue`. - internal typealias StringValue = Swift.String - /// A 64-bit integer value - /// - /// - Remark: Generated from `#/components/schemas/Int64Value`. - internal typealias Int64Value = Swift.Int64 - /// A double-precision floating point value - /// - /// - Remark: Generated from `#/components/schemas/DoubleValue`. - internal typealias DoubleValue = Swift.Double - /// Base64-encoded string representing binary data - /// - /// - Remark: Generated from `#/components/schemas/BytesValue`. - internal typealias BytesValue = Swift.String - /// Number representing milliseconds since epoch (January 1, 1970) - /// - /// - Remark: Generated from `#/components/schemas/DateValue`. - internal typealias DateValue = Swift.Double - /// Location dictionary as defined in CloudKit Web Services - /// - /// - Remark: Generated from `#/components/schemas/LocationValue`. - internal struct LocationValue: Codable, Hashable, Sendable { - /// Latitude in degrees - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/latitude`. - internal var latitude: Swift.Double? - /// Longitude in degrees - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/longitude`. - internal var longitude: Swift.Double? - /// Horizontal accuracy in meters - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/horizontalAccuracy`. - internal var horizontalAccuracy: Swift.Double? - /// Vertical accuracy in meters - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/verticalAccuracy`. - internal var verticalAccuracy: Swift.Double? - /// Altitude in meters - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/altitude`. - internal var altitude: Swift.Double? - /// Speed in meters per second - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/speed`. - internal var speed: Swift.Double? - /// Course in degrees - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/course`. - internal var course: Swift.Double? - /// Timestamp in milliseconds since epoch - /// - /// - Remark: Generated from `#/components/schemas/LocationValue/timestamp`. - internal var timestamp: Swift.Double? - /// Creates a new `LocationValue`. - /// - /// - Parameters: - /// - latitude: Latitude in degrees - /// - longitude: Longitude in degrees - /// - horizontalAccuracy: Horizontal accuracy in meters - /// - verticalAccuracy: Vertical accuracy in meters - /// - altitude: Altitude in meters - /// - speed: Speed in meters per second - /// - course: Course in degrees - /// - timestamp: Timestamp in milliseconds since epoch - internal init( - latitude: Swift.Double? = nil, - longitude: Swift.Double? = nil, - horizontalAccuracy: Swift.Double? = nil, - verticalAccuracy: Swift.Double? = nil, - altitude: Swift.Double? = nil, - speed: Swift.Double? = nil, - course: Swift.Double? = nil, - timestamp: Swift.Double? = nil - ) { - self.latitude = latitude - self.longitude = longitude - self.horizontalAccuracy = horizontalAccuracy - self.verticalAccuracy = verticalAccuracy - self.altitude = altitude - self.speed = speed - self.course = course - self.timestamp = timestamp - } - internal enum CodingKeys: String, CodingKey { - case latitude - case longitude - case horizontalAccuracy - case verticalAccuracy - case altitude - case speed - case course - case timestamp - } - } - /// Reference dictionary as defined in CloudKit Web Services - /// - /// - Remark: Generated from `#/components/schemas/ReferenceValue`. - internal struct ReferenceValue: Codable, Hashable, Sendable { - /// The record name being referenced - /// - /// - Remark: Generated from `#/components/schemas/ReferenceValue/recordName`. - internal var recordName: Swift.String? - /// Action to perform on the referenced record - /// - /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. - internal enum actionPayload: String, Codable, Hashable, Sendable, CaseIterable { - case NONE = "NONE" - case DELETE_SELF = "DELETE_SELF" - } - /// Action to perform on the referenced record - /// - /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. - internal var action: Components.Schemas.ReferenceValue.actionPayload? - /// Creates a new `ReferenceValue`. - /// - /// - Parameters: - /// - recordName: The record name being referenced - /// - action: Action to perform on the referenced record - internal init( - recordName: Swift.String? = nil, - action: Components.Schemas.ReferenceValue.actionPayload? = nil - ) { - self.recordName = recordName - self.action = action - } - internal enum CodingKeys: String, CodingKey { - case recordName - case action - } - } - /// Asset dictionary as defined in CloudKit Web Services - /// - /// - Remark: Generated from `#/components/schemas/AssetValue`. - internal struct AssetValue: Codable, Hashable, Sendable { - /// Checksum of the asset file - /// - /// - Remark: Generated from `#/components/schemas/AssetValue/fileChecksum`. - internal var fileChecksum: Swift.String? - /// Size of the asset in bytes - /// - /// - Remark: Generated from `#/components/schemas/AssetValue/size`. - internal var size: Swift.Int64? - /// Checksum of the asset reference - /// - /// - Remark: Generated from `#/components/schemas/AssetValue/referenceChecksum`. - internal var referenceChecksum: Swift.String? - /// Wrapping key for the asset - /// - /// - Remark: Generated from `#/components/schemas/AssetValue/wrappingKey`. - internal var wrappingKey: Swift.String? - /// Receipt for the asset - /// - /// - Remark: Generated from `#/components/schemas/AssetValue/receipt`. - internal var receipt: Swift.String? - /// URL for downloading the asset - /// - /// - Remark: Generated from `#/components/schemas/AssetValue/downloadURL`. - internal var downloadURL: Swift.String? - /// Creates a new `AssetValue`. - /// - /// - Parameters: - /// - fileChecksum: Checksum of the asset file - /// - size: Size of the asset in bytes - /// - referenceChecksum: Checksum of the asset reference - /// - wrappingKey: Wrapping key for the asset - /// - receipt: Receipt for the asset - /// - downloadURL: URL for downloading the asset - internal init( - fileChecksum: Swift.String? = nil, - size: Swift.Int64? = nil, - referenceChecksum: Swift.String? = nil, - wrappingKey: Swift.String? = nil, - receipt: Swift.String? = nil, - downloadURL: Swift.String? = nil - ) { - self.fileChecksum = fileChecksum - self.size = size - self.referenceChecksum = referenceChecksum - self.wrappingKey = wrappingKey - self.receipt = receipt - self.downloadURL = downloadURL - } - internal enum CodingKeys: String, CodingKey { - case fileChecksum - case size - case referenceChecksum - case wrappingKey - case receipt - case downloadURL - } - } - /// - Remark: Generated from `#/components/schemas/ListValue`. - internal indirect enum ListValuePayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ListValue/case1`. - case StringValue(Components.Schemas.StringValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case2`. - case Int64Value(Components.Schemas.Int64Value) - /// - Remark: Generated from `#/components/schemas/ListValue/case3`. - case DoubleValue(Components.Schemas.DoubleValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case4`. - case BytesValue(Components.Schemas.BytesValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case5`. - case DateValue(Components.Schemas.DateValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case6`. - case LocationValue(Components.Schemas.LocationValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case7`. - case ReferenceValue(Components.Schemas.ReferenceValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case8`. - case AssetValue(Components.Schemas.AssetValue) - /// - Remark: Generated from `#/components/schemas/ListValue/case9`. - case ListValue(Components.Schemas.ListValue) - internal init(from decoder: any Decoder) throws { - var errors: [any Error] = [] - do { - self = .StringValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .Int64Value(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .DoubleValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .BytesValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .DateValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - do { - self = .LocationValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .ReferenceValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .AssetValue(try .init(from: decoder)) - return - } catch { - errors.append(error) - } - do { - self = .ListValue(try decoder.decodeFromSingleValueContainer()) - return - } catch { - errors.append(error) - } - throw Swift.DecodingError.failedToDecodeOneOfSchema( - type: Self.self, - codingPath: decoder.codingPath, - errors: errors - ) - } - internal func encode(to encoder: any Encoder) throws { - switch self { - case let .StringValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .Int64Value(value): - try encoder.encodeToSingleValueContainer(value) - case let .DoubleValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .BytesValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .DateValue(value): - try encoder.encodeToSingleValueContainer(value) - case let .LocationValue(value): - try value.encode(to: encoder) - case let .ReferenceValue(value): - try value.encode(to: encoder) - case let .AssetValue(value): - try value.encode(to: encoder) - case let .ListValue(value): - try encoder.encodeToSingleValueContainer(value) - } - } - } - /// Array containing any of the above field types - /// - /// - Remark: Generated from `#/components/schemas/ListValue`. - internal typealias ListValue = [Components.Schemas.ListValuePayload] - /// - Remark: Generated from `#/components/schemas/ZoneOperation`. - internal struct ZoneOperation: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneOperation/operationType`. - internal enum operationTypePayload: String, Codable, Hashable, Sendable, CaseIterable { - case create = "create" - case delete = "delete" - } - /// - Remark: Generated from `#/components/schemas/ZoneOperation/operationType`. - internal var operationType: Components.Schemas.ZoneOperation.operationTypePayload? - /// - Remark: Generated from `#/components/schemas/ZoneOperation/zone`. - internal struct zonePayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneOperation/zone/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonePayload`. - /// - /// - Parameters: - /// - zoneID: - internal init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - internal enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZoneOperation/zone`. - internal var zone: Components.Schemas.ZoneOperation.zonePayload? - /// Creates a new `ZoneOperation`. - /// - /// - Parameters: - /// - operationType: - /// - zone: - internal init( - operationType: Components.Schemas.ZoneOperation.operationTypePayload? = nil, - zone: Components.Schemas.ZoneOperation.zonePayload? = nil - ) { - self.operationType = operationType - self.zone = zone - } - internal enum CodingKeys: String, CodingKey { - case operationType - case zone - } - } - /// - Remark: Generated from `#/components/schemas/SubscriptionOperation`. - internal struct SubscriptionOperation: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/SubscriptionOperation/operationType`. - internal enum operationTypePayload: String, Codable, Hashable, Sendable, CaseIterable { - case create = "create" - case update = "update" - case delete = "delete" - } - /// - Remark: Generated from `#/components/schemas/SubscriptionOperation/operationType`. - internal var operationType: Components.Schemas.SubscriptionOperation.operationTypePayload? - /// - Remark: Generated from `#/components/schemas/SubscriptionOperation/subscription`. - internal var subscription: Components.Schemas.Subscription? - /// Creates a new `SubscriptionOperation`. - /// - /// - Parameters: - /// - operationType: - /// - subscription: - internal init( - operationType: Components.Schemas.SubscriptionOperation.operationTypePayload? = nil, - subscription: Components.Schemas.Subscription? = nil - ) { - self.operationType = operationType - self.subscription = subscription - } - internal enum CodingKeys: String, CodingKey { - case operationType - case subscription - } - } - /// - Remark: Generated from `#/components/schemas/Subscription`. - internal struct Subscription: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionID`. - internal var subscriptionID: Swift.String? - /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionType`. - internal enum subscriptionTypePayload: String, Codable, Hashable, Sendable, CaseIterable { - case query = "query" - case zone = "zone" - } - /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionType`. - internal var subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? - /// - Remark: Generated from `#/components/schemas/Subscription/query`. - internal var query: OpenAPIRuntime.OpenAPIObjectContainer? - /// - Remark: Generated from `#/components/schemas/Subscription/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// - Remark: Generated from `#/components/schemas/Subscription/firesOnPayload`. - internal enum firesOnPayloadPayload: String, Codable, Hashable, Sendable, CaseIterable { - case create = "create" - case update = "update" - case delete = "delete" - } - /// - Remark: Generated from `#/components/schemas/Subscription/firesOn`. - internal typealias firesOnPayload = [Components.Schemas.Subscription.firesOnPayloadPayload] - /// - Remark: Generated from `#/components/schemas/Subscription/firesOn`. - internal var firesOn: Components.Schemas.Subscription.firesOnPayload? - /// Creates a new `Subscription`. - /// - /// - Parameters: - /// - subscriptionID: - /// - subscriptionType: - /// - query: - /// - zoneID: - /// - firesOn: - internal init( - subscriptionID: Swift.String? = nil, - subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? = nil, - query: OpenAPIRuntime.OpenAPIObjectContainer? = nil, - zoneID: Components.Schemas.ZoneID? = nil, - firesOn: Components.Schemas.Subscription.firesOnPayload? = nil - ) { - self.subscriptionID = subscriptionID - self.subscriptionType = subscriptionType - self.query = query - self.zoneID = zoneID - self.firesOn = firesOn - } - internal enum CodingKeys: String, CodingKey { - case subscriptionID - case subscriptionType - case query - case zoneID - case firesOn - } - } - /// - Remark: Generated from `#/components/schemas/QueryResponse`. - internal struct QueryResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/QueryResponse/records`. - internal var records: [Components.Schemas.RecordResponse]? - /// - Remark: Generated from `#/components/schemas/QueryResponse/continuationMarker`. - internal var continuationMarker: Swift.String? - /// Creates a new `QueryResponse`. - /// - /// - Parameters: - /// - records: - /// - continuationMarker: - internal init( - records: [Components.Schemas.RecordResponse]? = nil, - continuationMarker: Swift.String? = nil - ) { - self.records = records - self.continuationMarker = continuationMarker - } - internal enum CodingKeys: String, CodingKey { - case records - case continuationMarker - } - } - /// - Remark: Generated from `#/components/schemas/ModifyResponse`. - internal struct ModifyResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ModifyResponse/records`. - internal var records: [Components.Schemas.RecordResponse]? - /// Creates a new `ModifyResponse`. - /// - /// - Parameters: - /// - records: - internal init(records: [Components.Schemas.RecordResponse]? = nil) { - self.records = records - } - internal enum CodingKeys: String, CodingKey { - case records - } - } - /// - Remark: Generated from `#/components/schemas/LookupResponse`. - internal struct LookupResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/LookupResponse/records`. - internal var records: [Components.Schemas.RecordResponse]? - /// Creates a new `LookupResponse`. - /// - /// - Parameters: - /// - records: - internal init(records: [Components.Schemas.RecordResponse]? = nil) { - self.records = records - } - internal enum CodingKeys: String, CodingKey { - case records - } - } - /// - Remark: Generated from `#/components/schemas/ChangesResponse`. - internal struct ChangesResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ChangesResponse/records`. - internal var records: [Components.Schemas.RecordResponse]? - /// - Remark: Generated from `#/components/schemas/ChangesResponse/syncToken`. - internal var syncToken: Swift.String? - /// - Remark: Generated from `#/components/schemas/ChangesResponse/moreComing`. - internal var moreComing: Swift.Bool? - /// Creates a new `ChangesResponse`. - /// - /// - Parameters: - /// - records: - /// - syncToken: - /// - moreComing: - internal init( - records: [Components.Schemas.RecordResponse]? = nil, - syncToken: Swift.String? = nil, - moreComing: Swift.Bool? = nil - ) { - self.records = records - self.syncToken = syncToken - self.moreComing = moreComing - } - internal enum CodingKeys: String, CodingKey { - case records - case syncToken - case moreComing - } - } - /// - Remark: Generated from `#/components/schemas/ZonesListResponse`. - internal struct ZonesListResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zonesPayload`. - internal struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zonesPayload/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - internal init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - internal enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zones`. - internal typealias zonesPayload = [Components.Schemas.ZonesListResponse.zonesPayloadPayload] - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zones`. - internal var zones: Components.Schemas.ZonesListResponse.zonesPayload? - /// Creates a new `ZonesListResponse`. - /// - /// - Parameters: - /// - zones: - internal init(zones: Components.Schemas.ZonesListResponse.zonesPayload? = nil) { - self.zones = zones - } - internal enum CodingKeys: String, CodingKey { - case zones - } - } - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse`. - internal struct ZonesLookupResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zonesPayload`. - internal struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zonesPayload/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - internal init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - internal enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zones`. - internal typealias zonesPayload = [Components.Schemas.ZonesLookupResponse.zonesPayloadPayload] - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zones`. - internal var zones: Components.Schemas.ZonesLookupResponse.zonesPayload? - /// Creates a new `ZonesLookupResponse`. - /// - /// - Parameters: - /// - zones: - internal init(zones: Components.Schemas.ZonesLookupResponse.zonesPayload? = nil) { - self.zones = zones - } - internal enum CodingKeys: String, CodingKey { - case zones - } - } - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse`. - internal struct ZonesModifyResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload`. - internal struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - internal init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - internal enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. - internal typealias zonesPayload = [Components.Schemas.ZonesModifyResponse.zonesPayloadPayload] - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. - internal var zones: Components.Schemas.ZonesModifyResponse.zonesPayload? - /// Creates a new `ZonesModifyResponse`. - /// - /// - Parameters: - /// - zones: - internal init(zones: Components.Schemas.ZonesModifyResponse.zonesPayload? = nil) { - self.zones = zones - } - internal enum CodingKeys: String, CodingKey { - case zones - } - } - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse`. - internal struct ZoneChangesResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zonesPayload`. - internal struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zonesPayload/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - internal init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - internal enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. - internal typealias zonesPayload = [Components.Schemas.ZoneChangesResponse.zonesPayloadPayload] - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. - internal var zones: Components.Schemas.ZoneChangesResponse.zonesPayload? - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/syncToken`. - internal var syncToken: Swift.String? - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/moreComing`. - internal var moreComing: Swift.Bool? - /// Creates a new `ZoneChangesResponse`. - /// - /// - Parameters: - /// - zones: - /// - syncToken: - /// - moreComing: - internal init( - zones: Components.Schemas.ZoneChangesResponse.zonesPayload? = nil, - syncToken: Swift.String? = nil, - moreComing: Swift.Bool? = nil - ) { - self.zones = zones - self.syncToken = syncToken - self.moreComing = moreComing - } - internal enum CodingKeys: String, CodingKey { - case zones - case syncToken - case moreComing - } - } - /// - Remark: Generated from `#/components/schemas/SubscriptionsListResponse`. - internal struct SubscriptionsListResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/SubscriptionsListResponse/subscriptions`. - internal var subscriptions: [Components.Schemas.Subscription]? - /// Creates a new `SubscriptionsListResponse`. - /// - /// - Parameters: - /// - subscriptions: - internal init(subscriptions: [Components.Schemas.Subscription]? = nil) { - self.subscriptions = subscriptions - } - internal enum CodingKeys: String, CodingKey { - case subscriptions - } - } - /// - Remark: Generated from `#/components/schemas/SubscriptionsLookupResponse`. - internal struct SubscriptionsLookupResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/SubscriptionsLookupResponse/subscriptions`. - internal var subscriptions: [Components.Schemas.Subscription]? - /// Creates a new `SubscriptionsLookupResponse`. - /// - /// - Parameters: - /// - subscriptions: - internal init(subscriptions: [Components.Schemas.Subscription]? = nil) { - self.subscriptions = subscriptions - } - internal enum CodingKeys: String, CodingKey { - case subscriptions - } - } - /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse`. - internal struct SubscriptionsModifyResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptions`. - internal var subscriptions: [Components.Schemas.Subscription]? - /// Creates a new `SubscriptionsModifyResponse`. - /// - /// - Parameters: - /// - subscriptions: - internal init(subscriptions: [Components.Schemas.Subscription]? = nil) { - self.subscriptions = subscriptions - } - internal enum CodingKeys: String, CodingKey { - case subscriptions - } - } - /// Timestamp information for record creation or modification - /// - /// - Remark: Generated from `#/components/schemas/RecordTimestamp`. - internal struct RecordTimestamp: Codable, Hashable, Sendable { - /// Unix timestamp in milliseconds - /// - /// - Remark: Generated from `#/components/schemas/RecordTimestamp/timestamp`. - internal var timestamp: Swift.Double? - /// Record name of the user who performed the action - /// - /// - Remark: Generated from `#/components/schemas/RecordTimestamp/userRecordName`. - internal var userRecordName: Swift.String? - /// Creates a new `RecordTimestamp`. - /// - /// - Parameters: - /// - timestamp: Unix timestamp in milliseconds - /// - userRecordName: Record name of the user who performed the action - internal init( - timestamp: Swift.Double? = nil, - userRecordName: Swift.String? = nil - ) { - self.timestamp = timestamp - self.userRecordName = userRecordName - } - internal enum CodingKeys: String, CodingKey { - case timestamp - case userRecordName - } - } - /// The parts of a user's name - /// - /// - Remark: Generated from `#/components/schemas/NameComponents`. - internal struct NameComponents: Codable, Hashable, Sendable { - /// The user's name prefix - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/namePrefix`. - internal var namePrefix: Swift.String? - /// The user's first name - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/givenName`. - internal var givenName: Swift.String? - /// The user's middle name - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/middleName`. - internal var middleName: Swift.String? - /// The user's last name - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/familyName`. - internal var familyName: Swift.String? - /// The user's name suffix - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/nameSuffix`. - internal var nameSuffix: Swift.String? - /// The user's nickname - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/nickname`. - internal var nickname: Swift.String? - /// A phonetic representation of the user's name - /// - /// - Remark: Generated from `#/components/schemas/NameComponents/phoneticRepresentation`. - internal var phoneticRepresentation: Swift.String? - /// Creates a new `NameComponents`. - /// - /// - Parameters: - /// - namePrefix: The user's name prefix - /// - givenName: The user's first name - /// - middleName: The user's middle name - /// - familyName: The user's last name - /// - nameSuffix: The user's name suffix - /// - nickname: The user's nickname - /// - phoneticRepresentation: A phonetic representation of the user's name - internal init( - namePrefix: Swift.String? = nil, - givenName: Swift.String? = nil, - middleName: Swift.String? = nil, - familyName: Swift.String? = nil, - nameSuffix: Swift.String? = nil, - nickname: Swift.String? = nil, - phoneticRepresentation: Swift.String? = nil - ) { - self.namePrefix = namePrefix - self.givenName = givenName - self.middleName = middleName - self.familyName = familyName - self.nameSuffix = nameSuffix - self.nickname = nickname - self.phoneticRepresentation = phoneticRepresentation - } - internal enum CodingKeys: String, CodingKey { - case namePrefix - case givenName - case middleName - case familyName - case nameSuffix - case nickname - case phoneticRepresentation - } - } - /// Information used to look up a user identity - /// - /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo`. - internal struct UserIdentityLookupInfo: Codable, Hashable, Sendable { - /// The user's email address - /// - /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo/emailAddress`. - internal var emailAddress: Swift.String? - /// The user's phone number - /// - /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo/phoneNumber`. - internal var phoneNumber: Swift.String? - /// The record name of the user - /// - /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo/userRecordName`. - internal var userRecordName: Swift.String? - /// Creates a new `UserIdentityLookupInfo`. - /// - /// - Parameters: - /// - emailAddress: The user's email address - /// - phoneNumber: The user's phone number - /// - userRecordName: The record name of the user - internal init( - emailAddress: Swift.String? = nil, - phoneNumber: Swift.String? = nil, - userRecordName: Swift.String? = nil - ) { - self.emailAddress = emailAddress - self.phoneNumber = phoneNumber - self.userRecordName = userRecordName - } - internal enum CodingKeys: String, CodingKey { - case emailAddress - case phoneNumber - case userRecordName - } - } - /// A user identity returned by discover endpoints - /// - /// - Remark: Generated from `#/components/schemas/UserIdentity`. - internal struct UserIdentity: Codable, Hashable, Sendable { - /// The record name of the user - /// - /// - Remark: Generated from `#/components/schemas/UserIdentity/userRecordName`. - internal var userRecordName: Swift.String? - /// - Remark: Generated from `#/components/schemas/UserIdentity/nameComponents`. - internal var nameComponents: Components.Schemas.NameComponents? - /// - Remark: Generated from `#/components/schemas/UserIdentity/lookupInfo`. - internal var lookupInfo: Components.Schemas.UserIdentityLookupInfo? - /// Creates a new `UserIdentity`. - /// - /// - Parameters: - /// - userRecordName: The record name of the user - /// - nameComponents: - /// - lookupInfo: - internal init( - userRecordName: Swift.String? = nil, - nameComponents: Components.Schemas.NameComponents? = nil, - lookupInfo: Components.Schemas.UserIdentityLookupInfo? = nil - ) { - self.userRecordName = userRecordName - self.nameComponents = nameComponents - self.lookupInfo = lookupInfo - } - internal enum CodingKeys: String, CodingKey { - case userRecordName - case nameComponents - case lookupInfo - } - } - /// A user returned by current/lookup endpoints (User Dictionary) - /// - /// - Remark: Generated from `#/components/schemas/UserResponse`. - internal struct UserResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/UserResponse/userRecordName`. - internal var userRecordName: Swift.String? - /// - Remark: Generated from `#/components/schemas/UserResponse/firstName`. - internal var firstName: Swift.String? - /// - Remark: Generated from `#/components/schemas/UserResponse/lastName`. - internal var lastName: Swift.String? - /// - Remark: Generated from `#/components/schemas/UserResponse/emailAddress`. - internal var emailAddress: Swift.String? - /// Creates a new `UserResponse`. - /// - /// - Parameters: - /// - userRecordName: - /// - firstName: - /// - lastName: - /// - emailAddress: - internal init( - userRecordName: Swift.String? = nil, - firstName: Swift.String? = nil, - lastName: Swift.String? = nil, - emailAddress: Swift.String? = nil - ) { - self.userRecordName = userRecordName - self.firstName = firstName - self.lastName = lastName - self.emailAddress = emailAddress - } - internal enum CodingKeys: String, CodingKey { - case userRecordName - case firstName - case lastName - case emailAddress - } - } - /// - Remark: Generated from `#/components/schemas/DiscoverResponse`. - internal struct DiscoverResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/DiscoverResponse/users`. - internal var users: [Components.Schemas.UserIdentity]? - /// Creates a new `DiscoverResponse`. - /// - /// - Parameters: - /// - users: - internal init(users: [Components.Schemas.UserIdentity]? = nil) { - self.users = users - } - internal enum CodingKeys: String, CodingKey { - case users - } - } - /// - Remark: Generated from `#/components/schemas/ContactsResponse`. - internal struct ContactsResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ContactsResponse/contacts`. - internal var contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? - /// Creates a new `ContactsResponse`. - /// - /// - Parameters: - /// - contacts: - internal init(contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? = nil) { - self.contacts = contacts - } - internal enum CodingKeys: String, CodingKey { - case contacts - } - } - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse`. - internal struct AssetUploadResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload`. - internal struct tokensPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload/url`. - internal var url: Swift.String? - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload/recordName`. - internal var recordName: Swift.String? - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload/fieldName`. - internal var fieldName: Swift.String? - /// Creates a new `tokensPayloadPayload`. - /// - /// - Parameters: - /// - url: - /// - recordName: - /// - fieldName: - internal init( - url: Swift.String? = nil, - recordName: Swift.String? = nil, - fieldName: Swift.String? = nil - ) { - self.url = url - self.recordName = recordName - self.fieldName = fieldName - } - internal enum CodingKeys: String, CodingKey { - case url - case recordName - case fieldName - } - } - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokens`. - internal typealias tokensPayload = [Components.Schemas.AssetUploadResponse.tokensPayloadPayload] - /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokens`. - internal var tokens: Components.Schemas.AssetUploadResponse.tokensPayload? - /// Creates a new `AssetUploadResponse`. - /// - /// - Parameters: - /// - tokens: - internal init(tokens: Components.Schemas.AssetUploadResponse.tokensPayload? = nil) { - self.tokens = tokens - } - internal enum CodingKeys: String, CodingKey { - case tokens - } - } - /// - Remark: Generated from `#/components/schemas/TokenResponse`. - internal struct TokenResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/TokenResponse/apnsToken`. - internal var apnsToken: Swift.String? - /// - Remark: Generated from `#/components/schemas/TokenResponse/webcAuthToken`. - internal var webcAuthToken: Swift.String? - /// Creates a new `TokenResponse`. - /// - /// - Parameters: - /// - apnsToken: - /// - webcAuthToken: - internal init( - apnsToken: Swift.String? = nil, - webcAuthToken: Swift.String? = nil - ) { - self.apnsToken = apnsToken - self.webcAuthToken = webcAuthToken - } - internal enum CodingKeys: String, CodingKey { - case apnsToken - case webcAuthToken - } - } - /// Error response object. For a full list of error codes and meanings, see: - /// https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 - /// - /// Common error codes include: - /// - AUTHENTICATION_FAILED: The request could not be authenticated. - /// - ACCESS_DENIED: The user does not have permission to access the resource. - /// - INVALID_ARGUMENTS: The request contained invalid parameters. - /// - LIMIT_EXCEEDED: A request or resource limit was exceeded. - /// - NOT_FOUND: The requested resource does not exist. - /// - SERVICE_UNAVAILABLE: The service is temporarily unavailable. - /// - ZONE_NOT_FOUND: The specified zone does not exist. - /// - RECORD_NOT_FOUND: The specified record does not exist. - /// - PARTIAL_FAILURE: Some, but not all, operations succeeded. - /// - /// See the documentation for a complete list and details. - /// - /// - /// - Remark: Generated from `#/components/schemas/ErrorResponse`. - internal struct ErrorResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ErrorResponse/uuid`. - internal var uuid: Swift.String? - /// Server error code. See https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 for complete details. - /// - /// - /// - Remark: Generated from `#/components/schemas/ErrorResponse/serverErrorCode`. - internal enum serverErrorCodePayload: String, Codable, Hashable, Sendable, CaseIterable { - case ACCESS_DENIED = "ACCESS_DENIED" - case ATOMIC_ERROR = "ATOMIC_ERROR" - case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" - case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" - case BAD_REQUEST = "BAD_REQUEST" - case CONFLICT = "CONFLICT" - case EXISTS = "EXISTS" - case INTERNAL_ERROR = "INTERNAL_ERROR" - case NOT_FOUND = "NOT_FOUND" - case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" - case THROTTLED = "THROTTLED" - case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" - case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" - case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" - } - /// Server error code. See https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 for complete details. - /// - /// - /// - Remark: Generated from `#/components/schemas/ErrorResponse/serverErrorCode`. - internal var serverErrorCode: Components.Schemas.ErrorResponse.serverErrorCodePayload? - /// - Remark: Generated from `#/components/schemas/ErrorResponse/reason`. - internal var reason: Swift.String? - /// - Remark: Generated from `#/components/schemas/ErrorResponse/redirectURL`. - internal var redirectURL: Swift.String? - /// Creates a new `ErrorResponse`. - /// - /// - Parameters: - /// - uuid: - /// - serverErrorCode: Server error code. See https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 for complete details. - /// - reason: - /// - redirectURL: - internal init( - uuid: Swift.String? = nil, - serverErrorCode: Components.Schemas.ErrorResponse.serverErrorCodePayload? = nil, - reason: Swift.String? = nil, - redirectURL: Swift.String? = nil - ) { - self.uuid = uuid - self.serverErrorCode = serverErrorCode - self.reason = reason - self.redirectURL = redirectURL - } - internal enum CodingKeys: String, CodingKey { - case uuid - case serverErrorCode - case reason - case redirectURL - } - } - } - /// Types generated from the `#/components/parameters` section of the OpenAPI document. - internal enum Parameters { - /// Protocol version - /// - /// - Remark: Generated from `#/components/parameters/version`. - internal typealias version = Swift.String - /// Container ID (begins with "iCloud.") - /// - /// - Remark: Generated from `#/components/parameters/container`. - internal typealias container = Swift.String - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - } - /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. - internal enum RequestBodies {} - /// Types generated from the `#/components/responses` section of the OpenAPI document. - internal enum Responses { - internal struct BadRequest: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/BadRequest/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/BadRequest/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.BadRequest.Body - /// Creates a new `BadRequest`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.BadRequest.Body) { - self.body = body - } - } - internal struct Unauthorized: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/Unauthorized/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/Unauthorized/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.Unauthorized.Body - /// Creates a new `Unauthorized`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.Unauthorized.Body) { - self.body = body - } - } - internal struct Forbidden: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/Forbidden/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/Forbidden/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.Forbidden.Body - /// Creates a new `Forbidden`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.Forbidden.Body) { - self.body = body - } - } - internal struct NotFound: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/NotFound/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/NotFound/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.NotFound.Body - /// Creates a new `NotFound`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.NotFound.Body) { - self.body = body - } - } - internal struct Conflict: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/Conflict/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/Conflict/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.Conflict.Body - /// Creates a new `Conflict`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.Conflict.Body) { - self.body = body - } - } - internal struct PreconditionFailed: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/PreconditionFailed/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/PreconditionFailed/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.PreconditionFailed.Body - /// Creates a new `PreconditionFailed`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.PreconditionFailed.Body) { - self.body = body - } - } - internal struct RequestEntityTooLarge: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/RequestEntityTooLarge/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/RequestEntityTooLarge/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.RequestEntityTooLarge.Body - /// Creates a new `RequestEntityTooLarge`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.RequestEntityTooLarge.Body) { - self.body = body - } - } - internal struct TooManyRequests: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/TooManyRequests/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/TooManyRequests/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.TooManyRequests.Body - /// Creates a new `TooManyRequests`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.TooManyRequests.Body) { - self.body = body - } - } - internal struct UnprocessableEntity: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/UnprocessableEntity/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/UnprocessableEntity/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.UnprocessableEntity.Body - /// Creates a new `UnprocessableEntity`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.UnprocessableEntity.Body) { - self.body = body - } - } - internal struct InternalServerError: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/InternalServerError/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/InternalServerError/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.InternalServerError.Body - /// Creates a new `InternalServerError`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.InternalServerError.Body) { - self.body = body - } - } - internal struct ServiceUnavailable: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/ServiceUnavailable/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/components/responses/ServiceUnavailable/content/application\/json`. - case json(Components.Schemas.ErrorResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ErrorResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Components.Responses.ServiceUnavailable.Body - /// Creates a new `ServiceUnavailable`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Components.Responses.ServiceUnavailable.Body) { - self.body = body - } - } - } - /// Types generated from the `#/components/headers` section of the OpenAPI document. - internal enum Headers {} -} - -/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. -internal enum Operations { - /// Query Records - /// - /// Fetch records using a query with filters and sorting options - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)`. - internal enum queryRecords { - internal static let id: Swift.String = "queryRecords" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.queryRecords.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.queryRecords.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Maximum number of records to return - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/resultsLimit`. - internal var resultsLimit: Swift.Int? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query`. - internal struct queryPayload: Codable, Hashable, Sendable { - /// The record type to query - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/recordType`. - internal var recordType: Swift.String? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/filterBy`. - internal var filterBy: [Components.Schemas.Filter]? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/sortBy`. - internal var sortBy: [Components.Schemas.Sort]? - /// Creates a new `queryPayload`. - /// - /// - Parameters: - /// - recordType: The record type to query - /// - filterBy: - /// - sortBy: - internal init( - recordType: Swift.String? = nil, - filterBy: [Components.Schemas.Filter]? = nil, - sortBy: [Components.Schemas.Sort]? = nil - ) { - self.recordType = recordType - self.filterBy = filterBy - self.sortBy = sortBy - } - internal enum CodingKeys: String, CodingKey { - case recordType - case filterBy - case sortBy - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query`. - internal var query: Operations.queryRecords.Input.Body.jsonPayload.queryPayload? - /// List of field names to return - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/desiredKeys`. - internal var desiredKeys: [Swift.String]? - /// Marker for pagination - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/continuationMarker`. - internal var continuationMarker: Swift.String? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - zoneID: - /// - resultsLimit: Maximum number of records to return - /// - query: - /// - desiredKeys: List of field names to return - /// - continuationMarker: Marker for pagination - internal init( - zoneID: Components.Schemas.ZoneID? = nil, - resultsLimit: Swift.Int? = nil, - query: Operations.queryRecords.Input.Body.jsonPayload.queryPayload? = nil, - desiredKeys: [Swift.String]? = nil, - continuationMarker: Swift.String? = nil - ) { - self.zoneID = zoneID - self.resultsLimit = resultsLimit - self.query = query - self.desiredKeys = desiredKeys - self.continuationMarker = continuationMarker - } - internal enum CodingKeys: String, CodingKey { - case zoneID - case resultsLimit - case query - case desiredKeys - case continuationMarker - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/content/application\/json`. - case json(Operations.queryRecords.Input.Body.jsonPayload) - } - internal var body: Operations.queryRecords.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.queryRecords.Input.Path, - headers: Operations.queryRecords.Input.Headers = .init(), - body: Operations.queryRecords.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/responses/200/content/application\/json`. - case json(Components.Schemas.QueryResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.QueryResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.queryRecords.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.queryRecords.Output.Ok.Body) { - self.body = body - } - } - /// Successful query - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.queryRecords.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.queryRecords.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Forbidden (403) - ACCESS_DENIED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/403`. - /// - /// HTTP response code: `403 forbidden`. - case forbidden(Components.Responses.Forbidden) - /// The associated value of the enum case if `self` is `.forbidden`. - /// - /// - Throws: An error if `self` is not `.forbidden`. - /// - SeeAlso: `.forbidden`. - internal var forbidden: Components.Responses.Forbidden { - get throws { - switch self { - case let .forbidden(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "forbidden", - response: self - ) - } - } - } - /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/404`. - /// - /// HTTP response code: `404 notFound`. - case notFound(Components.Responses.NotFound) - /// The associated value of the enum case if `self` is `.notFound`. - /// - /// - Throws: An error if `self` is not `.notFound`. - /// - SeeAlso: `.notFound`. - internal var notFound: Components.Responses.NotFound { - get throws { - switch self { - case let .notFound(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "notFound", - response: self - ) - } - } - } - /// Conflict (409) - CONFLICT, EXISTS - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/409`. - /// - /// HTTP response code: `409 conflict`. - case conflict(Components.Responses.Conflict) - /// The associated value of the enum case if `self` is `.conflict`. - /// - /// - Throws: An error if `self` is not `.conflict`. - /// - SeeAlso: `.conflict`. - internal var conflict: Components.Responses.Conflict { - get throws { - switch self { - case let .conflict(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "conflict", - response: self - ) - } - } - } - /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/412`. - /// - /// HTTP response code: `412 preconditionFailed`. - case preconditionFailed(Components.Responses.PreconditionFailed) - /// The associated value of the enum case if `self` is `.preconditionFailed`. - /// - /// - Throws: An error if `self` is not `.preconditionFailed`. - /// - SeeAlso: `.preconditionFailed`. - internal var preconditionFailed: Components.Responses.PreconditionFailed { - get throws { - switch self { - case let .preconditionFailed(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "preconditionFailed", - response: self - ) - } - } - } - /// Request entity too large (413) - QUOTA_EXCEEDED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/413`. - /// - /// HTTP response code: `413 contentTooLarge`. - case contentTooLarge(Components.Responses.RequestEntityTooLarge) - /// The associated value of the enum case if `self` is `.contentTooLarge`. - /// - /// - Throws: An error if `self` is not `.contentTooLarge`. - /// - SeeAlso: `.contentTooLarge`. - internal var contentTooLarge: Components.Responses.RequestEntityTooLarge { - get throws { - switch self { - case let .contentTooLarge(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "contentTooLarge", - response: self - ) - } - } - } - /// Too many requests (429) - THROTTLED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/429`. - /// - /// HTTP response code: `429 tooManyRequests`. - case tooManyRequests(Components.Responses.TooManyRequests) - /// The associated value of the enum case if `self` is `.tooManyRequests`. - /// - /// - Throws: An error if `self` is not `.tooManyRequests`. - /// - SeeAlso: `.tooManyRequests`. - internal var tooManyRequests: Components.Responses.TooManyRequests { - get throws { - switch self { - case let .tooManyRequests(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "tooManyRequests", - response: self - ) - } - } - } - /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/421`. - /// - /// HTTP response code: `421 misdirectedRequest`. - case misdirectedRequest(Components.Responses.UnprocessableEntity) - /// The associated value of the enum case if `self` is `.misdirectedRequest`. - /// - /// - Throws: An error if `self` is not `.misdirectedRequest`. - /// - SeeAlso: `.misdirectedRequest`. - internal var misdirectedRequest: Components.Responses.UnprocessableEntity { - get throws { - switch self { - case let .misdirectedRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "misdirectedRequest", - response: self - ) - } - } - } - /// Internal server error (500) - INTERNAL_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/500`. - /// - /// HTTP response code: `500 internalServerError`. - case internalServerError(Components.Responses.InternalServerError) - /// The associated value of the enum case if `self` is `.internalServerError`. - /// - /// - Throws: An error if `self` is not `.internalServerError`. - /// - SeeAlso: `.internalServerError`. - internal var internalServerError: Components.Responses.InternalServerError { - get throws { - switch self { - case let .internalServerError(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "internalServerError", - response: self - ) - } - } - } - /// Service unavailable (503) - TRY_AGAIN_LATER - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/503`. - /// - /// HTTP response code: `503 serviceUnavailable`. - case serviceUnavailable(Components.Responses.ServiceUnavailable) - /// The associated value of the enum case if `self` is `.serviceUnavailable`. - /// - /// - Throws: An error if `self` is not `.serviceUnavailable`. - /// - SeeAlso: `.serviceUnavailable`. - internal var serviceUnavailable: Components.Responses.ServiceUnavailable { - get throws { - switch self { - case let .serviceUnavailable(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "serviceUnavailable", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Modify Records - /// - /// Create, update, or delete records (supports bulk operations) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. - internal enum modifyRecords { - internal static let id: Swift.String = "modifyRecords" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.modifyRecords.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.modifyRecords.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/operations`. - internal var operations: [Components.Schemas.RecordOperation]? - /// If true, all operations must succeed or all fail - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/atomic`. - internal var atomic: Swift.Bool? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - operations: - /// - atomic: If true, all operations must succeed or all fail - internal init( - operations: [Components.Schemas.RecordOperation]? = nil, - atomic: Swift.Bool? = nil - ) { - self.operations = operations - self.atomic = atomic - } - internal enum CodingKeys: String, CodingKey { - case operations - case atomic - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/content/application\/json`. - case json(Operations.modifyRecords.Input.Body.jsonPayload) - } - internal var body: Operations.modifyRecords.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.modifyRecords.Input.Path, - headers: Operations.modifyRecords.Input.Headers = .init(), - body: Operations.modifyRecords.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/responses/200/content/application\/json`. - case json(Components.Schemas.ModifyResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ModifyResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.modifyRecords.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.modifyRecords.Output.Ok.Body) { - self.body = body - } - } - /// Records modified successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.modifyRecords.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.modifyRecords.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Forbidden (403) - ACCESS_DENIED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/403`. - /// - /// HTTP response code: `403 forbidden`. - case forbidden(Components.Responses.Forbidden) - /// The associated value of the enum case if `self` is `.forbidden`. - /// - /// - Throws: An error if `self` is not `.forbidden`. - /// - SeeAlso: `.forbidden`. - internal var forbidden: Components.Responses.Forbidden { - get throws { - switch self { - case let .forbidden(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "forbidden", - response: self - ) - } - } - } - /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/404`. - /// - /// HTTP response code: `404 notFound`. - case notFound(Components.Responses.NotFound) - /// The associated value of the enum case if `self` is `.notFound`. - /// - /// - Throws: An error if `self` is not `.notFound`. - /// - SeeAlso: `.notFound`. - internal var notFound: Components.Responses.NotFound { - get throws { - switch self { - case let .notFound(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "notFound", - response: self - ) - } - } - } - /// Conflict (409) - CONFLICT, EXISTS - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/409`. - /// - /// HTTP response code: `409 conflict`. - case conflict(Components.Responses.Conflict) - /// The associated value of the enum case if `self` is `.conflict`. - /// - /// - Throws: An error if `self` is not `.conflict`. - /// - SeeAlso: `.conflict`. - internal var conflict: Components.Responses.Conflict { - get throws { - switch self { - case let .conflict(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "conflict", - response: self - ) - } - } - } - /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/412`. - /// - /// HTTP response code: `412 preconditionFailed`. - case preconditionFailed(Components.Responses.PreconditionFailed) - /// The associated value of the enum case if `self` is `.preconditionFailed`. - /// - /// - Throws: An error if `self` is not `.preconditionFailed`. - /// - SeeAlso: `.preconditionFailed`. - internal var preconditionFailed: Components.Responses.PreconditionFailed { - get throws { - switch self { - case let .preconditionFailed(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "preconditionFailed", - response: self - ) - } - } - } - /// Request entity too large (413) - QUOTA_EXCEEDED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/413`. - /// - /// HTTP response code: `413 contentTooLarge`. - case contentTooLarge(Components.Responses.RequestEntityTooLarge) - /// The associated value of the enum case if `self` is `.contentTooLarge`. - /// - /// - Throws: An error if `self` is not `.contentTooLarge`. - /// - SeeAlso: `.contentTooLarge`. - internal var contentTooLarge: Components.Responses.RequestEntityTooLarge { - get throws { - switch self { - case let .contentTooLarge(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "contentTooLarge", - response: self - ) - } - } - } - /// Too many requests (429) - THROTTLED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/429`. - /// - /// HTTP response code: `429 tooManyRequests`. - case tooManyRequests(Components.Responses.TooManyRequests) - /// The associated value of the enum case if `self` is `.tooManyRequests`. - /// - /// - Throws: An error if `self` is not `.tooManyRequests`. - /// - SeeAlso: `.tooManyRequests`. - internal var tooManyRequests: Components.Responses.TooManyRequests { - get throws { - switch self { - case let .tooManyRequests(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "tooManyRequests", - response: self - ) - } - } - } - /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/421`. - /// - /// HTTP response code: `421 misdirectedRequest`. - case misdirectedRequest(Components.Responses.UnprocessableEntity) - /// The associated value of the enum case if `self` is `.misdirectedRequest`. - /// - /// - Throws: An error if `self` is not `.misdirectedRequest`. - /// - SeeAlso: `.misdirectedRequest`. - internal var misdirectedRequest: Components.Responses.UnprocessableEntity { - get throws { - switch self { - case let .misdirectedRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "misdirectedRequest", - response: self - ) - } - } - } - /// Internal server error (500) - INTERNAL_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/500`. - /// - /// HTTP response code: `500 internalServerError`. - case internalServerError(Components.Responses.InternalServerError) - /// The associated value of the enum case if `self` is `.internalServerError`. - /// - /// - Throws: An error if `self` is not `.internalServerError`. - /// - SeeAlso: `.internalServerError`. - internal var internalServerError: Components.Responses.InternalServerError { - get throws { - switch self { - case let .internalServerError(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "internalServerError", - response: self - ) - } - } - } - /// Service unavailable (503) - TRY_AGAIN_LATER - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/503`. - /// - /// HTTP response code: `503 serviceUnavailable`. - case serviceUnavailable(Components.Responses.ServiceUnavailable) - /// The associated value of the enum case if `self` is `.serviceUnavailable`. - /// - /// - Throws: An error if `self` is not `.serviceUnavailable`. - /// - SeeAlso: `.serviceUnavailable`. - internal var serviceUnavailable: Components.Responses.ServiceUnavailable { - get throws { - switch self { - case let .serviceUnavailable(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "serviceUnavailable", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Lookup Records - /// - /// Fetch specific records by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. - internal enum lookupRecords { - internal static let id: Swift.String = "lookupRecords" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.lookupRecords.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.lookupRecords.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/recordsPayload`. - internal struct recordsPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/recordsPayload/recordName`. - internal var recordName: Swift.String? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/recordsPayload/desiredKeys`. - internal var desiredKeys: [Swift.String]? - /// Creates a new `recordsPayloadPayload`. - /// - /// - Parameters: - /// - recordName: - /// - desiredKeys: - internal init( - recordName: Swift.String? = nil, - desiredKeys: [Swift.String]? = nil - ) { - self.recordName = recordName - self.desiredKeys = desiredKeys - } - internal enum CodingKeys: String, CodingKey { - case recordName - case desiredKeys - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/records`. - internal typealias recordsPayload = [Operations.lookupRecords.Input.Body.jsonPayload.recordsPayloadPayload] - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/records`. - internal var records: Operations.lookupRecords.Input.Body.jsonPayload.recordsPayload? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - records: - internal init(records: Operations.lookupRecords.Input.Body.jsonPayload.recordsPayload? = nil) { - self.records = records - } - internal enum CodingKeys: String, CodingKey { - case records - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/content/application\/json`. - case json(Operations.lookupRecords.Input.Body.jsonPayload) - } - internal var body: Operations.lookupRecords.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.lookupRecords.Input.Path, - headers: Operations.lookupRecords.Input.Headers = .init(), - body: Operations.lookupRecords.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/responses/200/content/application\/json`. - case json(Components.Schemas.LookupResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.LookupResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.lookupRecords.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.lookupRecords.Output.Ok.Body) { - self.body = body - } - } - /// Records retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.lookupRecords.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.lookupRecords.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Forbidden (403) - ACCESS_DENIED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/403`. - /// - /// HTTP response code: `403 forbidden`. - case forbidden(Components.Responses.Forbidden) - /// The associated value of the enum case if `self` is `.forbidden`. - /// - /// - Throws: An error if `self` is not `.forbidden`. - /// - SeeAlso: `.forbidden`. - internal var forbidden: Components.Responses.Forbidden { - get throws { - switch self { - case let .forbidden(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "forbidden", - response: self - ) - } - } - } - /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/404`. - /// - /// HTTP response code: `404 notFound`. - case notFound(Components.Responses.NotFound) - /// The associated value of the enum case if `self` is `.notFound`. - /// - /// - Throws: An error if `self` is not `.notFound`. - /// - SeeAlso: `.notFound`. - internal var notFound: Components.Responses.NotFound { - get throws { - switch self { - case let .notFound(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "notFound", - response: self - ) - } - } - } - /// Conflict (409) - CONFLICT, EXISTS - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/409`. - /// - /// HTTP response code: `409 conflict`. - case conflict(Components.Responses.Conflict) - /// The associated value of the enum case if `self` is `.conflict`. - /// - /// - Throws: An error if `self` is not `.conflict`. - /// - SeeAlso: `.conflict`. - internal var conflict: Components.Responses.Conflict { - get throws { - switch self { - case let .conflict(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "conflict", - response: self - ) - } - } - } - /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/412`. - /// - /// HTTP response code: `412 preconditionFailed`. - case preconditionFailed(Components.Responses.PreconditionFailed) - /// The associated value of the enum case if `self` is `.preconditionFailed`. - /// - /// - Throws: An error if `self` is not `.preconditionFailed`. - /// - SeeAlso: `.preconditionFailed`. - internal var preconditionFailed: Components.Responses.PreconditionFailed { - get throws { - switch self { - case let .preconditionFailed(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "preconditionFailed", - response: self - ) - } - } - } - /// Request entity too large (413) - QUOTA_EXCEEDED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/413`. - /// - /// HTTP response code: `413 contentTooLarge`. - case contentTooLarge(Components.Responses.RequestEntityTooLarge) - /// The associated value of the enum case if `self` is `.contentTooLarge`. - /// - /// - Throws: An error if `self` is not `.contentTooLarge`. - /// - SeeAlso: `.contentTooLarge`. - internal var contentTooLarge: Components.Responses.RequestEntityTooLarge { - get throws { - switch self { - case let .contentTooLarge(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "contentTooLarge", - response: self - ) - } - } - } - /// Too many requests (429) - THROTTLED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/429`. - /// - /// HTTP response code: `429 tooManyRequests`. - case tooManyRequests(Components.Responses.TooManyRequests) - /// The associated value of the enum case if `self` is `.tooManyRequests`. - /// - /// - Throws: An error if `self` is not `.tooManyRequests`. - /// - SeeAlso: `.tooManyRequests`. - internal var tooManyRequests: Components.Responses.TooManyRequests { - get throws { - switch self { - case let .tooManyRequests(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "tooManyRequests", - response: self - ) - } - } - } - /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/421`. - /// - /// HTTP response code: `421 misdirectedRequest`. - case misdirectedRequest(Components.Responses.UnprocessableEntity) - /// The associated value of the enum case if `self` is `.misdirectedRequest`. - /// - /// - Throws: An error if `self` is not `.misdirectedRequest`. - /// - SeeAlso: `.misdirectedRequest`. - internal var misdirectedRequest: Components.Responses.UnprocessableEntity { - get throws { - switch self { - case let .misdirectedRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "misdirectedRequest", - response: self - ) - } - } - } - /// Internal server error (500) - INTERNAL_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/500`. - /// - /// HTTP response code: `500 internalServerError`. - case internalServerError(Components.Responses.InternalServerError) - /// The associated value of the enum case if `self` is `.internalServerError`. - /// - /// - Throws: An error if `self` is not `.internalServerError`. - /// - SeeAlso: `.internalServerError`. - internal var internalServerError: Components.Responses.InternalServerError { - get throws { - switch self { - case let .internalServerError(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "internalServerError", - response: self - ) - } - } - } - /// Service unavailable (503) - TRY_AGAIN_LATER - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/503`. - /// - /// HTTP response code: `503 serviceUnavailable`. - case serviceUnavailable(Components.Responses.ServiceUnavailable) - /// The associated value of the enum case if `self` is `.serviceUnavailable`. - /// - /// - Throws: An error if `self` is not `.serviceUnavailable`. - /// - SeeAlso: `.serviceUnavailable`. - internal var serviceUnavailable: Components.Responses.ServiceUnavailable { - get throws { - switch self { - case let .serviceUnavailable(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "serviceUnavailable", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Fetch Record Changes - /// - /// Get all record changes relative to a sync token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. - internal enum fetchRecordChanges { - internal static let id: Swift.String = "fetchRecordChanges" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.fetchRecordChanges.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.fetchRecordChanges.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// Token from previous sync operation - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/syncToken`. - internal var syncToken: Swift.String? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/resultsLimit`. - internal var resultsLimit: Swift.Int? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - zoneID: - /// - syncToken: Token from previous sync operation - /// - resultsLimit: - internal init( - zoneID: Components.Schemas.ZoneID? = nil, - syncToken: Swift.String? = nil, - resultsLimit: Swift.Int? = nil - ) { - self.zoneID = zoneID - self.syncToken = syncToken - self.resultsLimit = resultsLimit - } - internal enum CodingKeys: String, CodingKey { - case zoneID - case syncToken - case resultsLimit - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/content/application\/json`. - case json(Operations.fetchRecordChanges.Input.Body.jsonPayload) - } - internal var body: Operations.fetchRecordChanges.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.fetchRecordChanges.Input.Path, - headers: Operations.fetchRecordChanges.Input.Headers = .init(), - body: Operations.fetchRecordChanges.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/responses/200/content/application\/json`. - case json(Components.Schemas.ChangesResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ChangesResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.fetchRecordChanges.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.fetchRecordChanges.Output.Ok.Body) { - self.body = body - } - } - /// Changes retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.fetchRecordChanges.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.fetchRecordChanges.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Forbidden (403) - ACCESS_DENIED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/403`. - /// - /// HTTP response code: `403 forbidden`. - case forbidden(Components.Responses.Forbidden) - /// The associated value of the enum case if `self` is `.forbidden`. - /// - /// - Throws: An error if `self` is not `.forbidden`. - /// - SeeAlso: `.forbidden`. - internal var forbidden: Components.Responses.Forbidden { - get throws { - switch self { - case let .forbidden(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "forbidden", - response: self - ) - } - } - } - /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/404`. - /// - /// HTTP response code: `404 notFound`. - case notFound(Components.Responses.NotFound) - /// The associated value of the enum case if `self` is `.notFound`. - /// - /// - Throws: An error if `self` is not `.notFound`. - /// - SeeAlso: `.notFound`. - internal var notFound: Components.Responses.NotFound { - get throws { - switch self { - case let .notFound(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "notFound", - response: self - ) - } - } - } - /// Conflict (409) - CONFLICT, EXISTS - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/409`. - /// - /// HTTP response code: `409 conflict`. - case conflict(Components.Responses.Conflict) - /// The associated value of the enum case if `self` is `.conflict`. - /// - /// - Throws: An error if `self` is not `.conflict`. - /// - SeeAlso: `.conflict`. - internal var conflict: Components.Responses.Conflict { - get throws { - switch self { - case let .conflict(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "conflict", - response: self - ) - } - } - } - /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/412`. - /// - /// HTTP response code: `412 preconditionFailed`. - case preconditionFailed(Components.Responses.PreconditionFailed) - /// The associated value of the enum case if `self` is `.preconditionFailed`. - /// - /// - Throws: An error if `self` is not `.preconditionFailed`. - /// - SeeAlso: `.preconditionFailed`. - internal var preconditionFailed: Components.Responses.PreconditionFailed { - get throws { - switch self { - case let .preconditionFailed(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "preconditionFailed", - response: self - ) - } - } - } - /// Request entity too large (413) - QUOTA_EXCEEDED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/413`. - /// - /// HTTP response code: `413 contentTooLarge`. - case contentTooLarge(Components.Responses.RequestEntityTooLarge) - /// The associated value of the enum case if `self` is `.contentTooLarge`. - /// - /// - Throws: An error if `self` is not `.contentTooLarge`. - /// - SeeAlso: `.contentTooLarge`. - internal var contentTooLarge: Components.Responses.RequestEntityTooLarge { - get throws { - switch self { - case let .contentTooLarge(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "contentTooLarge", - response: self - ) - } - } - } - /// Too many requests (429) - THROTTLED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/429`. - /// - /// HTTP response code: `429 tooManyRequests`. - case tooManyRequests(Components.Responses.TooManyRequests) - /// The associated value of the enum case if `self` is `.tooManyRequests`. - /// - /// - Throws: An error if `self` is not `.tooManyRequests`. - /// - SeeAlso: `.tooManyRequests`. - internal var tooManyRequests: Components.Responses.TooManyRequests { - get throws { - switch self { - case let .tooManyRequests(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "tooManyRequests", - response: self - ) - } - } - } - /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/421`. - /// - /// HTTP response code: `421 misdirectedRequest`. - case misdirectedRequest(Components.Responses.UnprocessableEntity) - /// The associated value of the enum case if `self` is `.misdirectedRequest`. - /// - /// - Throws: An error if `self` is not `.misdirectedRequest`. - /// - SeeAlso: `.misdirectedRequest`. - internal var misdirectedRequest: Components.Responses.UnprocessableEntity { - get throws { - switch self { - case let .misdirectedRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "misdirectedRequest", - response: self - ) - } - } - } - /// Internal server error (500) - INTERNAL_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/500`. - /// - /// HTTP response code: `500 internalServerError`. - case internalServerError(Components.Responses.InternalServerError) - /// The associated value of the enum case if `self` is `.internalServerError`. - /// - /// - Throws: An error if `self` is not `.internalServerError`. - /// - SeeAlso: `.internalServerError`. - internal var internalServerError: Components.Responses.InternalServerError { - get throws { - switch self { - case let .internalServerError(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "internalServerError", - response: self - ) - } - } - } - /// Service unavailable (503) - TRY_AGAIN_LATER - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/503`. - /// - /// HTTP response code: `503 serviceUnavailable`. - case serviceUnavailable(Components.Responses.ServiceUnavailable) - /// The associated value of the enum case if `self` is `.serviceUnavailable`. - /// - /// - Throws: An error if `self` is not `.serviceUnavailable`. - /// - SeeAlso: `.serviceUnavailable`. - internal var serviceUnavailable: Components.Responses.ServiceUnavailable { - get throws { - switch self { - case let .serviceUnavailable(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "serviceUnavailable", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// List All Zones - /// - /// Fetch all zones in the database - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. - internal enum listZones { - internal static let id: Swift.String = "listZones" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.listZones.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.listZones.Input.Headers - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - internal init( - path: Operations.listZones.Input.Path, - headers: Operations.listZones.Input.Headers = .init() - ) { - self.path = path - self.headers = headers - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/responses/200/content/application\/json`. - case json(Components.Schemas.ZonesListResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ZonesListResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.listZones.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.listZones.Output.Ok.Body) { - self.body = body - } - } - /// Zones retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.listZones.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.listZones.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Forbidden (403) - ACCESS_DENIED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/403`. - /// - /// HTTP response code: `403 forbidden`. - case forbidden(Components.Responses.Forbidden) - /// The associated value of the enum case if `self` is `.forbidden`. - /// - /// - Throws: An error if `self` is not `.forbidden`. - /// - SeeAlso: `.forbidden`. - internal var forbidden: Components.Responses.Forbidden { - get throws { - switch self { - case let .forbidden(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "forbidden", - response: self - ) - } - } - } - /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/404`. - /// - /// HTTP response code: `404 notFound`. - case notFound(Components.Responses.NotFound) - /// The associated value of the enum case if `self` is `.notFound`. - /// - /// - Throws: An error if `self` is not `.notFound`. - /// - SeeAlso: `.notFound`. - internal var notFound: Components.Responses.NotFound { - get throws { - switch self { - case let .notFound(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "notFound", - response: self - ) - } - } - } - /// Conflict (409) - CONFLICT, EXISTS - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/409`. - /// - /// HTTP response code: `409 conflict`. - case conflict(Components.Responses.Conflict) - /// The associated value of the enum case if `self` is `.conflict`. - /// - /// - Throws: An error if `self` is not `.conflict`. - /// - SeeAlso: `.conflict`. - internal var conflict: Components.Responses.Conflict { - get throws { - switch self { - case let .conflict(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "conflict", - response: self - ) - } - } - } - /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/412`. - /// - /// HTTP response code: `412 preconditionFailed`. - case preconditionFailed(Components.Responses.PreconditionFailed) - /// The associated value of the enum case if `self` is `.preconditionFailed`. - /// - /// - Throws: An error if `self` is not `.preconditionFailed`. - /// - SeeAlso: `.preconditionFailed`. - internal var preconditionFailed: Components.Responses.PreconditionFailed { - get throws { - switch self { - case let .preconditionFailed(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "preconditionFailed", - response: self - ) - } - } - } - /// Request entity too large (413) - QUOTA_EXCEEDED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/413`. - /// - /// HTTP response code: `413 contentTooLarge`. - case contentTooLarge(Components.Responses.RequestEntityTooLarge) - /// The associated value of the enum case if `self` is `.contentTooLarge`. - /// - /// - Throws: An error if `self` is not `.contentTooLarge`. - /// - SeeAlso: `.contentTooLarge`. - internal var contentTooLarge: Components.Responses.RequestEntityTooLarge { - get throws { - switch self { - case let .contentTooLarge(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "contentTooLarge", - response: self - ) - } - } - } - /// Too many requests (429) - THROTTLED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/429`. - /// - /// HTTP response code: `429 tooManyRequests`. - case tooManyRequests(Components.Responses.TooManyRequests) - /// The associated value of the enum case if `self` is `.tooManyRequests`. - /// - /// - Throws: An error if `self` is not `.tooManyRequests`. - /// - SeeAlso: `.tooManyRequests`. - internal var tooManyRequests: Components.Responses.TooManyRequests { - get throws { - switch self { - case let .tooManyRequests(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "tooManyRequests", - response: self - ) - } - } - } - /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/421`. - /// - /// HTTP response code: `421 misdirectedRequest`. - case misdirectedRequest(Components.Responses.UnprocessableEntity) - /// The associated value of the enum case if `self` is `.misdirectedRequest`. - /// - /// - Throws: An error if `self` is not `.misdirectedRequest`. - /// - SeeAlso: `.misdirectedRequest`. - internal var misdirectedRequest: Components.Responses.UnprocessableEntity { - get throws { - switch self { - case let .misdirectedRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "misdirectedRequest", - response: self - ) - } - } - } - /// Internal server error (500) - INTERNAL_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/500`. - /// - /// HTTP response code: `500 internalServerError`. - case internalServerError(Components.Responses.InternalServerError) - /// The associated value of the enum case if `self` is `.internalServerError`. - /// - /// - Throws: An error if `self` is not `.internalServerError`. - /// - SeeAlso: `.internalServerError`. - internal var internalServerError: Components.Responses.InternalServerError { - get throws { - switch self { - case let .internalServerError(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "internalServerError", - response: self - ) - } - } - } - /// Service unavailable (503) - TRY_AGAIN_LATER - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/503`. - /// - /// HTTP response code: `503 serviceUnavailable`. - case serviceUnavailable(Components.Responses.ServiceUnavailable) - /// The associated value of the enum case if `self` is `.serviceUnavailable`. - /// - /// - Throws: An error if `self` is not `.serviceUnavailable`. - /// - SeeAlso: `.serviceUnavailable`. - internal var serviceUnavailable: Components.Responses.ServiceUnavailable { - get throws { - switch self { - case let .serviceUnavailable(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "serviceUnavailable", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Lookup Zones - /// - /// Fetch specific zones by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. - internal enum lookupZones { - internal static let id: Swift.String = "lookupZones" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.lookupZones.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.lookupZones.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody/json/zones`. - internal var zones: [Components.Schemas.ZoneID]? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - zones: - internal init(zones: [Components.Schemas.ZoneID]? = nil) { - self.zones = zones - } - internal enum CodingKeys: String, CodingKey { - case zones - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody/content/application\/json`. - case json(Operations.lookupZones.Input.Body.jsonPayload) - } - internal var body: Operations.lookupZones.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.lookupZones.Input.Path, - headers: Operations.lookupZones.Input.Headers = .init(), - body: Operations.lookupZones.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/responses/200/content/application\/json`. - case json(Components.Schemas.ZonesLookupResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ZonesLookupResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.lookupZones.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.lookupZones.Output.Ok.Body) { - self.body = body - } - } - /// Zones retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.lookupZones.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.lookupZones.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Modify Zones - /// - /// Create or delete zones (only supported in private database) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. - internal enum modifyZones { - internal static let id: Swift.String = "modifyZones" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.modifyZones.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.modifyZones.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody/json/operations`. - internal var operations: [Components.Schemas.ZoneOperation]? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - operations: - internal init(operations: [Components.Schemas.ZoneOperation]? = nil) { - self.operations = operations - } - internal enum CodingKeys: String, CodingKey { - case operations - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody/content/application\/json`. - case json(Operations.modifyZones.Input.Body.jsonPayload) - } - internal var body: Operations.modifyZones.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.modifyZones.Input.Path, - headers: Operations.modifyZones.Input.Headers = .init(), - body: Operations.modifyZones.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/responses/200/content/application\/json`. - case json(Components.Schemas.ZonesModifyResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ZonesModifyResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.modifyZones.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.modifyZones.Output.Ok.Body) { - self.body = body - } - } - /// Zones modified successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.modifyZones.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.modifyZones.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Fetch Zone Changes - /// - /// Get all changed zones relative to a meta-sync token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. - internal enum fetchZoneChanges { - internal static let id: Swift.String = "fetchZoneChanges" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.fetchZoneChanges.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.fetchZoneChanges.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// Meta-sync token from previous operation - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json/syncToken`. - internal var syncToken: Swift.String? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - syncToken: Meta-sync token from previous operation - internal init(syncToken: Swift.String? = nil) { - self.syncToken = syncToken - } - internal enum CodingKeys: String, CodingKey { - case syncToken - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/content/application\/json`. - case json(Operations.fetchZoneChanges.Input.Body.jsonPayload) - } - internal var body: Operations.fetchZoneChanges.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.fetchZoneChanges.Input.Path, - headers: Operations.fetchZoneChanges.Input.Headers = .init(), - body: Operations.fetchZoneChanges.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/responses/200/content/application\/json`. - case json(Components.Schemas.ZoneChangesResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ZoneChangesResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.fetchZoneChanges.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.fetchZoneChanges.Output.Ok.Body) { - self.body = body - } - } - /// Zone changes retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.fetchZoneChanges.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.fetchZoneChanges.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// List All Subscriptions - /// - /// Fetch all subscriptions in the database - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. - internal enum listSubscriptions { - internal static let id: Swift.String = "listSubscriptions" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.listSubscriptions.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.listSubscriptions.Input.Headers - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - internal init( - path: Operations.listSubscriptions.Input.Path, - headers: Operations.listSubscriptions.Input.Headers = .init() - ) { - self.path = path - self.headers = headers - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/responses/200/content/application\/json`. - case json(Components.Schemas.SubscriptionsListResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.SubscriptionsListResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.listSubscriptions.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.listSubscriptions.Output.Ok.Body) { - self.body = body - } - } - /// Subscriptions retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.listSubscriptions.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.listSubscriptions.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Lookup Subscriptions - /// - /// Fetch specific subscriptions by their IDs - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. - internal enum lookupSubscriptions { - internal static let id: Swift.String = "lookupSubscriptions" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.lookupSubscriptions.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.lookupSubscriptions.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptionsPayload`. - internal struct subscriptionsPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptionsPayload/subscriptionID`. - internal var subscriptionID: Swift.String? - /// Creates a new `subscriptionsPayloadPayload`. - /// - /// - Parameters: - /// - subscriptionID: - internal init(subscriptionID: Swift.String? = nil) { - self.subscriptionID = subscriptionID - } - internal enum CodingKeys: String, CodingKey { - case subscriptionID - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptions`. - internal typealias subscriptionsPayload = [Operations.lookupSubscriptions.Input.Body.jsonPayload.subscriptionsPayloadPayload] - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptions`. - internal var subscriptions: Operations.lookupSubscriptions.Input.Body.jsonPayload.subscriptionsPayload? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - subscriptions: - internal init(subscriptions: Operations.lookupSubscriptions.Input.Body.jsonPayload.subscriptionsPayload? = nil) { - self.subscriptions = subscriptions - } - internal enum CodingKeys: String, CodingKey { - case subscriptions - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/content/application\/json`. - case json(Operations.lookupSubscriptions.Input.Body.jsonPayload) - } - internal var body: Operations.lookupSubscriptions.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.lookupSubscriptions.Input.Path, - headers: Operations.lookupSubscriptions.Input.Headers = .init(), - body: Operations.lookupSubscriptions.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/responses/200/content/application\/json`. - case json(Components.Schemas.SubscriptionsLookupResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.SubscriptionsLookupResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.lookupSubscriptions.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.lookupSubscriptions.Output.Ok.Body) { - self.body = body - } - } - /// Subscriptions retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.lookupSubscriptions.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.lookupSubscriptions.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Modify Subscriptions - /// - /// Create, update, or delete subscriptions - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. - internal enum modifySubscriptions { - internal static let id: Swift.String = "modifySubscriptions" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.modifySubscriptions.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.modifySubscriptions.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody/json/operations`. - internal var operations: [Components.Schemas.SubscriptionOperation]? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - operations: - internal init(operations: [Components.Schemas.SubscriptionOperation]? = nil) { - self.operations = operations - } - internal enum CodingKeys: String, CodingKey { - case operations - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody/content/application\/json`. - case json(Operations.modifySubscriptions.Input.Body.jsonPayload) - } - internal var body: Operations.modifySubscriptions.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.modifySubscriptions.Input.Path, - headers: Operations.modifySubscriptions.Input.Headers = .init(), - body: Operations.modifySubscriptions.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/responses/200/content/application\/json`. - case json(Components.Schemas.SubscriptionsModifyResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.SubscriptionsModifyResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.modifySubscriptions.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.modifySubscriptions.Output.Ok.Body) { - self.body = body - } - } - /// Subscriptions modified successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.modifySubscriptions.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.modifySubscriptions.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Get Current User - /// - /// Fetch the current authenticated user's information - /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - internal enum getCurrentUser { - internal static let id: Swift.String = "getCurrentUser" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.getCurrentUser.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.getCurrentUser.Input.Headers - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - internal init( - path: Operations.getCurrentUser.Input.Path, - headers: Operations.getCurrentUser.Input.Headers = .init() - ) { - self.path = path - self.headers = headers - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/responses/200/content/application\/json`. - case json(Components.Schemas.UserResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.UserResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.getCurrentUser.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.getCurrentUser.Output.Ok.Body) { - self.body = body - } - } - /// User information retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.getCurrentUser.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.getCurrentUser.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Forbidden (403) - ACCESS_DENIED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/403`. - /// - /// HTTP response code: `403 forbidden`. - case forbidden(Components.Responses.Forbidden) - /// The associated value of the enum case if `self` is `.forbidden`. - /// - /// - Throws: An error if `self` is not `.forbidden`. - /// - SeeAlso: `.forbidden`. - internal var forbidden: Components.Responses.Forbidden { - get throws { - switch self { - case let .forbidden(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "forbidden", - response: self - ) - } - } - } - /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/404`. - /// - /// HTTP response code: `404 notFound`. - case notFound(Components.Responses.NotFound) - /// The associated value of the enum case if `self` is `.notFound`. - /// - /// - Throws: An error if `self` is not `.notFound`. - /// - SeeAlso: `.notFound`. - internal var notFound: Components.Responses.NotFound { - get throws { - switch self { - case let .notFound(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "notFound", - response: self - ) - } - } - } - /// Conflict (409) - CONFLICT, EXISTS - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/409`. - /// - /// HTTP response code: `409 conflict`. - case conflict(Components.Responses.Conflict) - /// The associated value of the enum case if `self` is `.conflict`. - /// - /// - Throws: An error if `self` is not `.conflict`. - /// - SeeAlso: `.conflict`. - internal var conflict: Components.Responses.Conflict { - get throws { - switch self { - case let .conflict(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "conflict", - response: self - ) - } - } - } - /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/412`. - /// - /// HTTP response code: `412 preconditionFailed`. - case preconditionFailed(Components.Responses.PreconditionFailed) - /// The associated value of the enum case if `self` is `.preconditionFailed`. - /// - /// - Throws: An error if `self` is not `.preconditionFailed`. - /// - SeeAlso: `.preconditionFailed`. - internal var preconditionFailed: Components.Responses.PreconditionFailed { - get throws { - switch self { - case let .preconditionFailed(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "preconditionFailed", - response: self - ) - } - } - } - /// Request entity too large (413) - QUOTA_EXCEEDED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/413`. - /// - /// HTTP response code: `413 contentTooLarge`. - case contentTooLarge(Components.Responses.RequestEntityTooLarge) - /// The associated value of the enum case if `self` is `.contentTooLarge`. - /// - /// - Throws: An error if `self` is not `.contentTooLarge`. - /// - SeeAlso: `.contentTooLarge`. - internal var contentTooLarge: Components.Responses.RequestEntityTooLarge { - get throws { - switch self { - case let .contentTooLarge(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "contentTooLarge", - response: self - ) - } - } - } - /// Too many requests (429) - THROTTLED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/429`. - /// - /// HTTP response code: `429 tooManyRequests`. - case tooManyRequests(Components.Responses.TooManyRequests) - /// The associated value of the enum case if `self` is `.tooManyRequests`. - /// - /// - Throws: An error if `self` is not `.tooManyRequests`. - /// - SeeAlso: `.tooManyRequests`. - internal var tooManyRequests: Components.Responses.TooManyRequests { - get throws { - switch self { - case let .tooManyRequests(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "tooManyRequests", - response: self - ) - } - } - } - /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/421`. - /// - /// HTTP response code: `421 misdirectedRequest`. - case misdirectedRequest(Components.Responses.UnprocessableEntity) - /// The associated value of the enum case if `self` is `.misdirectedRequest`. - /// - /// - Throws: An error if `self` is not `.misdirectedRequest`. - /// - SeeAlso: `.misdirectedRequest`. - internal var misdirectedRequest: Components.Responses.UnprocessableEntity { - get throws { - switch self { - case let .misdirectedRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "misdirectedRequest", - response: self - ) - } - } - } - /// Internal server error (500) - INTERNAL_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/500`. - /// - /// HTTP response code: `500 internalServerError`. - case internalServerError(Components.Responses.InternalServerError) - /// The associated value of the enum case if `self` is `.internalServerError`. - /// - /// - Throws: An error if `self` is not `.internalServerError`. - /// - SeeAlso: `.internalServerError`. - internal var internalServerError: Components.Responses.InternalServerError { - get throws { - switch self { - case let .internalServerError(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "internalServerError", - response: self - ) - } - } - } - /// Service unavailable (503) - TRY_AGAIN_LATER - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/503`. - /// - /// HTTP response code: `503 serviceUnavailable`. - case serviceUnavailable(Components.Responses.ServiceUnavailable) - /// The associated value of the enum case if `self` is `.serviceUnavailable`. - /// - /// - Throws: An error if `self` is not `.serviceUnavailable`. - /// - SeeAlso: `.serviceUnavailable`. - internal var serviceUnavailable: Components.Responses.ServiceUnavailable { - get throws { - switch self { - case let .serviceUnavailable(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "serviceUnavailable", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Discover User Identities - /// - /// Discover all user identities based on email addresses or user record names - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. - internal enum discoverUserIdentities { - internal static let id: Swift.String = "discoverUserIdentities" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.discoverUserIdentities.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.discoverUserIdentities.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload`. - internal struct lookupInfosPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload/emailAddress`. - internal var emailAddress: Swift.String? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload/phoneNumber`. - internal var phoneNumber: Swift.String? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload/userRecordName`. - internal var userRecordName: Swift.String? - /// Creates a new `lookupInfosPayloadPayload`. - /// - /// - Parameters: - /// - emailAddress: - /// - phoneNumber: - /// - userRecordName: - internal init( - emailAddress: Swift.String? = nil, - phoneNumber: Swift.String? = nil, - userRecordName: Swift.String? = nil - ) { - self.emailAddress = emailAddress - self.phoneNumber = phoneNumber - self.userRecordName = userRecordName - } - internal enum CodingKeys: String, CodingKey { - case emailAddress - case phoneNumber - case userRecordName - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfos`. - internal typealias lookupInfosPayload = [Operations.discoverUserIdentities.Input.Body.jsonPayload.lookupInfosPayloadPayload] - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfos`. - internal var lookupInfos: Operations.discoverUserIdentities.Input.Body.jsonPayload.lookupInfosPayload? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - lookupInfos: - internal init(lookupInfos: Operations.discoverUserIdentities.Input.Body.jsonPayload.lookupInfosPayload? = nil) { - self.lookupInfos = lookupInfos - } - internal enum CodingKeys: String, CodingKey { - case lookupInfos - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/content/application\/json`. - case json(Operations.discoverUserIdentities.Input.Body.jsonPayload) - } - internal var body: Operations.discoverUserIdentities.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.discoverUserIdentities.Input.Path, - headers: Operations.discoverUserIdentities.Input.Headers = .init(), - body: Operations.discoverUserIdentities.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/responses/200/content/application\/json`. - case json(Components.Schemas.DiscoverResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.DiscoverResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.discoverUserIdentities.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.discoverUserIdentities.Output.Ok.Body) { - self.body = body - } - } - /// User identities discovered successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.discoverUserIdentities.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.discoverUserIdentities.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Lookup Contacts (Deprecated) - /// - /// Fetch contacts (This endpoint is deprecated) - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. - internal enum lookupContacts { - internal static let id: Swift.String = "lookupContacts" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.lookupContacts.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.lookupContacts.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody/json/contacts`. - internal var contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - contacts: - internal init(contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? = nil) { - self.contacts = contacts - } - internal enum CodingKeys: String, CodingKey { - case contacts - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody/content/application\/json`. - case json(Operations.lookupContacts.Input.Body.jsonPayload) - } - internal var body: Operations.lookupContacts.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.lookupContacts.Input.Path, - headers: Operations.lookupContacts.Input.Headers = .init(), - body: Operations.lookupContacts.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/responses/200/content/application\/json`. - case json(Components.Schemas.ContactsResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.ContactsResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.lookupContacts.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.lookupContacts.Output.Ok.Body) { - self.body = body - } - } - /// Contacts retrieved successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.lookupContacts.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.lookupContacts.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Request Asset Upload URLs - /// - /// Request upload URLs for asset fields. This is the first step in a two-step process: - /// 1. Request upload URLs by specifying the record type and field name - /// 2. Upload the actual binary data to the returned URL (separate HTTP request) - /// - /// Upload URLs are valid for 15 minutes. Maximum file size is 15 MB. - /// - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. - internal enum uploadAssets { - internal static let id: Swift.String = "uploadAssets" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.uploadAssets.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.uploadAssets.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/zoneID`. - internal var zoneID: Components.Schemas.ZoneID? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload`. - internal struct tokensPayloadPayload: Codable, Hashable, Sendable { - /// Unique name to identify the record. Defaults to random UUID if not specified. - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload/recordName`. - internal var recordName: Swift.String? - /// Name of the record type - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload/recordType`. - internal var recordType: Swift.String - /// Name of the Asset or Asset list field - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload/fieldName`. - internal var fieldName: Swift.String - /// Creates a new `tokensPayloadPayload`. - /// - /// - Parameters: - /// - recordName: Unique name to identify the record. Defaults to random UUID if not specified. - /// - recordType: Name of the record type - /// - fieldName: Name of the Asset or Asset list field - internal init( - recordName: Swift.String? = nil, - recordType: Swift.String, - fieldName: Swift.String - ) { - self.recordName = recordName - self.recordType = recordType - self.fieldName = fieldName - } - internal enum CodingKeys: String, CodingKey { - case recordName - case recordType - case fieldName - } - } - /// Array of asset fields to request upload URLs for - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokens`. - internal typealias tokensPayload = [Operations.uploadAssets.Input.Body.jsonPayload.tokensPayloadPayload] - /// Array of asset fields to request upload URLs for - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokens`. - internal var tokens: Operations.uploadAssets.Input.Body.jsonPayload.tokensPayload - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - zoneID: - /// - tokens: Array of asset fields to request upload URLs for - internal init( - zoneID: Components.Schemas.ZoneID? = nil, - tokens: Operations.uploadAssets.Input.Body.jsonPayload.tokensPayload - ) { - self.zoneID = zoneID - self.tokens = tokens - } - internal enum CodingKeys: String, CodingKey { - case zoneID - case tokens - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/content/application\/json`. - case json(Operations.uploadAssets.Input.Body.jsonPayload) - } - internal var body: Operations.uploadAssets.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.uploadAssets.Input.Path, - headers: Operations.uploadAssets.Input.Headers = .init(), - body: Operations.uploadAssets.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/responses/200/content/application\/json`. - case json(Components.Schemas.AssetUploadResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.AssetUploadResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.uploadAssets.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.uploadAssets.Output.Ok.Body) { - self.body = body - } - } - /// Upload URLs returned successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.uploadAssets.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.uploadAssets.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Create APNs Token - /// - /// Create an Apple Push Notification service (APNs) token - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. - internal enum createToken { - internal static let id: Swift.String = "createToken" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.createToken.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.createToken.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json/apnsEnvironment`. - internal enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json/apnsEnvironment`. - internal var apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - apnsEnvironment: - internal init(apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? = nil) { - self.apnsEnvironment = apnsEnvironment - } - internal enum CodingKeys: String, CodingKey { - case apnsEnvironment - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/content/application\/json`. - case json(Operations.createToken.Input.Body.jsonPayload) - } - internal var body: Operations.createToken.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.createToken.Input.Path, - headers: Operations.createToken.Input.Headers = .init(), - body: Operations.createToken.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/responses/200/content`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/responses/200/content/application\/json`. - case json(Components.Schemas.TokenResponse) - /// The associated value of the enum case if `self` is `.json`. - /// - /// - Throws: An error if `self` is not `.json`. - /// - SeeAlso: `.json`. - internal var json: Components.Schemas.TokenResponse { - get throws { - switch self { - case let .json(body): - return body - } - } - } - } - /// Received HTTP response body - internal var body: Operations.createToken.Output.Ok.Body - /// Creates a new `Ok`. - /// - /// - Parameters: - /// - body: Received HTTP response body - internal init(body: Operations.createToken.Output.Ok.Body) { - self.body = body - } - } - /// Token created successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.createToken.Output.Ok) - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.createToken.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } - /// Register Token - /// - /// Register a token for push notifications - /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. - internal enum registerToken { - internal static let id: Swift.String = "registerToken" - internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path`. - internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/version`. - internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/container`. - internal var container: Components.Parameters.container - /// Container environment - /// - /// - Remark: Generated from `#/components/parameters/environment`. - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { - case development = "development" - case production = "production" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/environment`. - internal var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/database`. - internal var database: Components.Parameters.database - /// Creates a new `Path`. - /// - /// - Parameters: - /// - version: - /// - container: - /// - environment: - /// - database: - internal init( - version: Components.Parameters.version, - container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database - ) { - self.version = version - self.container = container - self.environment = environment - self.database = database - } - } - internal var path: Operations.registerToken.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/header`. - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] - /// Creates a new `Headers`. - /// - /// - Parameters: - /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { - self.accept = accept - } - } - internal var headers: Operations.registerToken.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody`. - internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/json`. - internal struct jsonPayload: Codable, Hashable, Sendable { - /// The APNs token to register - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/json/apnsToken`. - internal var apnsToken: Swift.String? - /// Creates a new `jsonPayload`. - /// - /// - Parameters: - /// - apnsToken: The APNs token to register - internal init(apnsToken: Swift.String? = nil) { - self.apnsToken = apnsToken - } - internal enum CodingKeys: String, CodingKey { - case apnsToken - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/content/application\/json`. - case json(Operations.registerToken.Input.Body.jsonPayload) - } - internal var body: Operations.registerToken.Input.Body - /// Creates a new `Input`. - /// - /// - Parameters: - /// - path: - /// - headers: - /// - body: - internal init( - path: Operations.registerToken.Input.Path, - headers: Operations.registerToken.Input.Headers = .init(), - body: Operations.registerToken.Input.Body - ) { - self.path = path - self.headers = headers - self.body = body - } - } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - /// Creates a new `Ok`. - internal init() {} - } - /// Token registered successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/200`. - /// - /// HTTP response code: `200 ok`. - case ok(Operations.registerToken.Output.Ok) - /// Token registered successfully - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/200`. - /// - /// HTTP response code: `200 ok`. - internal static var ok: Self { - .ok(.init()) - } - /// The associated value of the enum case if `self` is `.ok`. - /// - /// - Throws: An error if `self` is not `.ok`. - /// - SeeAlso: `.ok`. - internal var ok: Operations.registerToken.Output.Ok { - get throws { - switch self { - case let .ok(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "ok", - response: self - ) - } - } - } - /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/400`. - /// - /// HTTP response code: `400 badRequest`. - case badRequest(Components.Responses.BadRequest) - /// The associated value of the enum case if `self` is `.badRequest`. - /// - /// - Throws: An error if `self` is not `.badRequest`. - /// - SeeAlso: `.badRequest`. - internal var badRequest: Components.Responses.BadRequest { - get throws { - switch self { - case let .badRequest(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "badRequest", - response: self - ) - } - } - } - /// Unauthorized (401) - AUTHENTICATION_FAILED - /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/401`. - /// - /// HTTP response code: `401 unauthorized`. - case unauthorized(Components.Responses.Unauthorized) - /// The associated value of the enum case if `self` is `.unauthorized`. - /// - /// - Throws: An error if `self` is not `.unauthorized`. - /// - SeeAlso: `.unauthorized`. - internal var unauthorized: Components.Responses.Unauthorized { - get throws { - switch self { - case let .unauthorized(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "unauthorized", - response: self - ) - } - } - } - /// Undocumented response. - /// - /// A response with a code that is not documented in the OpenAPI document. - case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) - } - internal enum AcceptableContentType: AcceptableProtocol { - case json - case other(Swift.String) - internal init?(rawValue: Swift.String) { - switch rawValue.lowercased() { - case "application/json": - self = .json - default: - self = .other(rawValue) - } - } - internal var rawValue: Swift.String { - switch self { - case let .other(string): - return string - case .json: - return "application/json" - } - } - internal static var allCases: [Self] { - [ - .json - ] - } - } - } -} diff --git a/Sources/MistKit/Helpers/SortDescriptor.swift b/Sources/MistKit/Helpers/SortDescriptor.swift deleted file mode 100644 index d0a0ded6..00000000 --- a/Sources/MistKit/Helpers/SortDescriptor.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// SortDescriptor.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// A builder for constructing CloudKit query sort descriptors -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -internal struct SortDescriptor { - // MARK: - Lifecycle - - private init() {} - - // MARK: - Public - - /// Creates an ascending sort descriptor - /// - Parameter field: The field name to sort by - /// - Returns: A configured Sort - internal static func ascending(_ field: String) -> Components.Schemas.Sort { - .init(fieldName: field, ascending: true) - } - - /// Creates a descending sort descriptor - /// - Parameter field: The field name to sort by - /// - Returns: A configured Sort - internal static func descending(_ field: String) -> Components.Schemas.Sort { - .init(fieldName: field, ascending: false) - } - - /// Creates a sort descriptor with explicit direction - /// - Parameters: - /// - field: The field name to sort by - /// - ascending: Whether to sort in ascending order - /// - Returns: A configured Sort - internal static func sort(_ field: String, ascending: Bool = true) -> Components.Schemas.Sort { - .init(fieldName: field, ascending: ascending) - } -} diff --git a/Sources/MistKit/Logging/MistKitLogger.swift b/Sources/MistKit/Logging/MistKitLogger.swift deleted file mode 100644 index aa04351a..00000000 --- a/Sources/MistKit/Logging/MistKitLogger.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// MistKitLogger.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Logging - -/// Centralized logging infrastructure for MistKit -internal enum MistKitLogger { - // MARK: - Subsystems - - /// Logger for CloudKit API operations - internal static let api = Logger(label: "com.brightdigit.MistKit.api") - - /// Logger for authentication and token management - internal static let auth = Logger(label: "com.brightdigit.MistKit.auth") - - /// Logger for network operations - internal static let network = Logger(label: "com.brightdigit.MistKit.network") - - // MARK: - Log Redaction Control - - /// Check if log redaction is disabled via environment variable - internal static var isRedactionDisabled: Bool { - ProcessInfo.processInfo.environment["MISTKIT_DISABLE_LOG_REDACTION"] == "1" - } - - // MARK: - Logging Helpers - - /// Log error with optional redaction - internal static func logError( - _ message: String, - logger: Logger, - shouldRedact: Bool = true - ) { - let finalMessage = - (isRedactionDisabled || !shouldRedact) ? message : SecureLogging.safeLogMessage(message) - logger.error("\(finalMessage)") - } - - /// Log warning with optional redaction - internal static func logWarning( - _ message: String, - logger: Logger, - shouldRedact: Bool = true - ) { - let finalMessage = - (isRedactionDisabled || !shouldRedact) ? message : SecureLogging.safeLogMessage(message) - logger.warning("\(finalMessage)") - } - - /// Log info with optional redaction - internal static func logInfo( - _ message: String, - logger: Logger, - shouldRedact: Bool = true - ) { - let finalMessage = - (isRedactionDisabled || !shouldRedact) ? message : SecureLogging.safeLogMessage(message) - logger.info("\(finalMessage)") - } - - /// Log debug with optional redaction - internal static func logDebug( - _ message: String, - logger: Logger, - shouldRedact: Bool = true - ) { - let finalMessage = - (isRedactionDisabled || !shouldRedact) ? message : SecureLogging.safeLogMessage(message) - logger.debug("\(finalMessage)") - } -} diff --git a/Sources/MistKit/LoggingMiddleware.swift b/Sources/MistKit/LoggingMiddleware.swift deleted file mode 100644 index 219841f3..00000000 --- a/Sources/MistKit/LoggingMiddleware.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// LoggingMiddleware.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import Logging -import OpenAPIRuntime - -/// Logging middleware for debugging -internal struct LoggingMiddleware: ClientMiddleware { - #if DEBUG - /// Logger for middleware HTTP request/response logging - private let logger = Logger(label: "com.brightdigit.MistKit.middleware") - #endif - internal func intercept( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) - ) async throws -> (HTTPResponse, HTTPBody?) { - #if DEBUG - logRequest(request, baseURL: baseURL) - #endif - - let (response, responseBody) = try await next(request, body, baseURL) - - #if DEBUG - let finalResponseBody = await logResponse(response, body: responseBody) - return (response, finalResponseBody) - #else - return (response, responseBody) - #endif - } - - #if DEBUG - /// Log outgoing request details - private func logRequest(_ request: HTTPRequest, baseURL: URL) { - let fullPath = baseURL.absoluteString + (request.path ?? "") - logger.debug("🌐 CloudKit Request: \(request.method.rawValue) \(fullPath)") - logger.debug(" Base URL: \(baseURL.absoluteString)") - logger.debug(" Path: \(request.path ?? "none")") - logger.debug(" Headers: \(request.headerFields)") - - logQueryParameters(for: request, baseURL: baseURL) - } - - /// Log query parameters from request - private func logQueryParameters(for request: HTTPRequest, baseURL: URL) { - guard let path = request.path, - let url = URL(string: path, relativeTo: baseURL), - let components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let queryItems = components.queryItems - else { - return - } - - logger.debug(" Query Parameters:") - for item in queryItems { - let value = formatQueryValue(for: item) - logger.debug(" \(item.name): \(value)") - } - } - - /// Format query parameter value for logging - private func formatQueryValue(for item: URLQueryItem) -> String { - guard let value = item.value else { - return "nil" - } - - // Mask sensitive query parameters - let lowercasedName = item.name.lowercased() - if lowercasedName.contains("token") || lowercasedName.contains("key") - || lowercasedName.contains("secret") || lowercasedName.contains("auth") - { - return SecureLogging.maskToken(value) - } - - return value - } - - /// Log incoming response details - private func logResponse(_ response: HTTPResponse, body: HTTPBody?) async -> HTTPBody? { - logger.debug("✅ CloudKit Response: \(response.status.code)") - - if response.status.code == 421 { - logger.warning( - "⚠️ 421 Misdirected Request - The server cannot produce a response for this request" - ) - } - - #if !os(WASI) - return await logResponseBody(body) - #else - return body - #endif - } - - /// Log response body content - private func logResponseBody(_ responseBody: HTTPBody?) async -> HTTPBody? { - guard let responseBody = responseBody else { - return nil - } - - do { - let bodyData = try await Data(collecting: responseBody, upTo: 1_024 * 1_024) - logBodyData(bodyData) - return HTTPBody(bodyData) - } catch { - logger.error("📄 Response Body: ") - return responseBody - } - } - - /// Log the actual body data content - private func logBodyData(_ bodyData: Data) { - if let jsonString = String(data: bodyData, encoding: .utf8) { - logger.debug("📄 Response Body:") - logger.debug("\(SecureLogging.safeLogMessage(jsonString))") - } else { - logger.debug("📄 Response Body: ") - } - } - #endif -} diff --git a/Sources/MistKit/MistKitClient.swift b/Sources/MistKit/MistKitClient.swift deleted file mode 100644 index d5a868d8..00000000 --- a/Sources/MistKit/MistKitClient.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// MistKitClient.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if !os(WASI) - import OpenAPIURLSession -#endif - -/// A client for interacting with CloudKit Web Services -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -internal struct MistKitClient { - /// The underlying OpenAPI client - internal let client: Client - - /// Initialize a new MistKit client - /// - Parameters: - /// - configuration: The CloudKit configuration including container, - /// environment, and authentication - /// - transport: Custom transport for network requests - /// - Throws: ClientError if initialization fails - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal init( - configuration: MistKitConfiguration, - transport: any ClientTransport - ) throws { - // Create appropriate TokenManager from configuration - let tokenManager = try configuration.createTokenManager() - - // Create the OpenAPI client with custom server URL and middleware - self.client = Client( - serverURL: configuration.serverURL, - transport: transport, - middlewares: [ - AuthenticationMiddleware(tokenManager: tokenManager), - LoggingMiddleware(), - ] - ) - } - - /// Initialize a new MistKit client with a custom TokenManager - /// - Parameters: - /// - configuration: The CloudKit configuration - /// - tokenManager: Custom token manager for authentication - /// - transport: Custom transport for network requests - /// - Throws: ClientError if initialization fails - internal init( - configuration: MistKitConfiguration, - tokenManager: any TokenManager, - transport: any ClientTransport - ) throws { - // Validate server-to-server authentication restrictions - try Self.validateServerToServerConfiguration( - configuration: configuration, - tokenManager: tokenManager - ) - - // Create the OpenAPI client with custom server URL and middleware - self.client = Client( - serverURL: configuration.serverURL, - transport: transport, - middlewares: [ - AuthenticationMiddleware(tokenManager: tokenManager), - LoggingMiddleware(), - ] - ) - } - - /// Initialize a new MistKit client with a custom TokenManager and individual parameters - /// - Parameters: - /// - container: CloudKit container identifier - /// - environment: CloudKit environment (development/production) - /// - database: CloudKit database (public/private/shared) - /// - tokenManager: Custom token manager for authentication - /// - transport: Custom transport for network requests - /// - Throws: ClientError if initialization fails - internal init( - container: String, - environment: Environment, - database: Database, - tokenManager: any TokenManager, - transport: any ClientTransport - ) throws { - // Check if this is a server-to-server token manager - var keyID: String? - var privateKeyData: Data? - var apiToken: String = "" - - if let serverManager = tokenManager as? ServerToServerAuthManager { - // Extract keyID and privateKeyData from ServerToServerAuthManager - keyID = serverManager.keyIdentifier - privateKeyData = serverManager.privateKeyData - } else if let apiManager = tokenManager as? APITokenManager { - // Extract API token from APITokenManager - apiToken = apiManager.token - } - - let configuration = MistKitConfiguration( - container: container, - environment: environment, - database: database, - apiToken: apiToken, // Use extracted API token if available - keyID: keyID, - privateKeyData: privateKeyData - ) - - try self.init( - configuration: configuration, - tokenManager: tokenManager, - transport: transport - ) - } - - // MARK: - Convenience Initializers - - #if !os(WASI) - /// Initialize a new MistKit client with default URLSessionTransport - /// - Parameters: - /// - configuration: The CloudKit configuration including container, - /// environment, and authentication - /// - Throws: ClientError if initialization fails - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal init( - configuration: MistKitConfiguration - ) throws { - try self.init( - configuration: configuration, - transport: URLSessionTransport() - ) - } - - /// Initialize a new MistKit client with a custom TokenManager and individual parameters - /// using default URLSessionTransport - /// - Parameters: - /// - container: CloudKit container identifier - /// - environment: CloudKit environment (development/production) - /// - database: CloudKit database (public/private/shared) - /// - tokenManager: Custom token manager for authentication - /// - Throws: ClientError if initialization fails - internal init( - container: String, - environment: Environment, - database: Database, - tokenManager: any TokenManager - ) throws { - try self.init( - container: container, - environment: environment, - database: database, - tokenManager: tokenManager, - transport: URLSessionTransport() - ) - } - #endif - - // MARK: - Server-to-Server Validation - - /// Validates that server-to-server authentication is only used with the public database - /// - Parameters: - /// - configuration: The CloudKit configuration - /// - tokenManager: The token manager being used - /// - Throws: TokenManagerError if server-to-server auth is used with non-public database - private static func validateServerToServerConfiguration( - configuration: MistKitConfiguration, - tokenManager: any TokenManager - ) throws { - // Check if this is a server-to-server token manager - if tokenManager is ServerToServerAuthManager { - // Server-to-server authentication only supports the public database - guard configuration.database == .public else { - throw TokenManagerError.invalidCredentials( - .serverToServerOnlySupportsPublicDatabase(configuration.database.rawValue) - ) - } - } - } -} diff --git a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift deleted file mode 100644 index cd36532c..00000000 --- a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// MistKitConfiguration+ConvenienceInitializers.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -extension MistKitConfiguration { - /// Initialize configuration with API token only (container-level access) - /// - Parameters: - /// - container: The CloudKit container identifier - /// - environment: The CloudKit environment - /// - database: The database type (default: .private) - /// - apiToken: The API token - /// - Returns: A configured MistKitConfiguration for API token authentication - public static func apiToken( - container: String, - environment: Environment, - database: Database = .private, - apiToken: String - ) -> MistKitConfiguration { - MistKitConfiguration( - container: container, - environment: environment, - database: database, - apiToken: apiToken, - webAuthToken: nil, - keyID: nil, - privateKeyData: nil - ) - } - - /// Initialize configuration with web authentication (user-specific access) - /// - Parameters: - /// - container: The CloudKit container identifier - /// - environment: The CloudKit environment - /// - database: The database type (default: .private) - /// - apiToken: The API token - /// - webAuthToken: The web authentication token - /// - Returns: A configured MistKitConfiguration for web authentication - public static func webAuth( - container: String, - environment: Environment, - database: Database = .private, - apiToken: String, - webAuthToken: String - ) -> MistKitConfiguration { - MistKitConfiguration( - container: container, - environment: environment, - database: database, - apiToken: apiToken, - webAuthToken: webAuthToken, - keyID: nil, - privateKeyData: nil - ) - } - - /// Initialize configuration for server-to-server authentication (public database only) - /// Server-to-server authentication in CloudKit Web Services only supports the public database - /// - Parameters: - /// - container: The CloudKit container identifier - /// - environment: The CloudKit environment - /// - keyID: The key identifier from Apple Developer Console - /// - privateKeyData: The private key as raw data (32 bytes for P-256) - /// - Returns: A configured MistKitConfiguration for server-to-server authentication - /// - Note: Database is automatically set to .public as server-to-server authentication - /// only supports the public database in CloudKit Web Services - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public static func serverToServer( - container: String, - environment: Environment, - keyID: String, - privateKeyData: Data - ) -> MistKitConfiguration { - MistKitConfiguration( - container: container, - environment: environment, - database: .public, // Server-to-server only supports public database - apiToken: "", // Not used with server-to-server auth - webAuthToken: nil, - keyID: keyID, - privateKeyData: privateKeyData - ) - } -} diff --git a/Sources/MistKit/MistKitConfiguration.swift b/Sources/MistKit/MistKitConfiguration.swift deleted file mode 100644 index a24fe516..00000000 --- a/Sources/MistKit/MistKitConfiguration.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// MistKitConfiguration.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Configuration for MistKit client -internal struct MistKitConfiguration: Sendable { - /// The CloudKit container identifier (e.g., "iCloud.com.example.app") - internal let container: String - - /// The CloudKit environment - internal let environment: Environment - - /// The CloudKit database type - internal let database: Database - - /// API Token for authentication - internal let apiToken: String - - /// Optional Web Auth Token for user authentication - internal let webAuthToken: String? - - /// Optional Key ID for server-to-server authentication - internal let keyID: String? - - /// Optional private key data for server-to-server authentication - internal let privateKeyData: Data? - - /// Protocol version (currently "1") - internal let version: String = "1" - - internal let serverURL: URL - - /// Initialize MistKit configuration - internal init( - container: String, - environment: Environment, - database: Database = .private, - serverURL: URL = .MistKit.cloudKitAPI, - apiToken: String, - webAuthToken: String? = nil, - keyID: String? = nil, - privateKeyData: Data? = nil - ) { - self.container = container - self.environment = environment - self.database = database - self.serverURL = serverURL - self.apiToken = apiToken - self.webAuthToken = webAuthToken - self.keyID = keyID - self.privateKeyData = privateKeyData - } - - /// Creates an appropriate TokenManager based on the configuration - /// - Returns: A TokenManager instance matching the authentication method - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal func createTokenManager() throws -> any TokenManager { - // Default creation logic - if let keyID = keyID, let privateKeyData = privateKeyData { - return try ServerToServerAuthManager( - keyID: keyID, - privateKeyData: privateKeyData - ) - } else if let webAuthToken = webAuthToken { - return WebAuthTokenManager( - apiToken: apiToken, - webAuthToken: webAuthToken - ) - } else { - return APITokenManager(apiToken: apiToken) - } - } -} diff --git a/Sources/MistKit/Service/AssetUploadReceipt.swift b/Sources/MistKit/Models/AssetUploading/AssetUploadReceipt.swift similarity index 88% rename from Sources/MistKit/Service/AssetUploadReceipt.swift rename to Sources/MistKit/Models/AssetUploading/AssetUploadReceipt.swift index 2387daa5..43bd5ff4 100644 --- a/Sources/MistKit/Service/AssetUploadReceipt.swift +++ b/Sources/MistKit/Models/AssetUploading/AssetUploadReceipt.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -35,7 +35,7 @@ public struct AssetUploadReceipt: Codable, Sendable { /// The complete asset data including receipt and checksums /// Use this when creating or updating records - public let asset: FieldValue.Asset + public let asset: Asset /// The record name this asset is associated with public let recordName: String @@ -44,7 +44,7 @@ public struct AssetUploadReceipt: Codable, Sendable { public let fieldName: String /// Initialize an asset upload receipt - public init(asset: FieldValue.Asset, recordName: String, fieldName: String) { + public init(asset: Asset, recordName: String, fieldName: String) { self.asset = asset self.recordName = recordName self.fieldName = fieldName diff --git a/Sources/MistKit/Service/AssetUploadResponse.swift b/Sources/MistKit/Models/AssetUploading/AssetUploadResponse.swift similarity index 94% rename from Sources/MistKit/Service/AssetUploadResponse.swift rename to Sources/MistKit/Models/AssetUploading/AssetUploadResponse.swift index 8946a72e..9aae7f15 100644 --- a/Sources/MistKit/Service/AssetUploadResponse.swift +++ b/Sources/MistKit/Models/AssetUploading/AssetUploadResponse.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Response structure for CloudKit CDN asset upload /// diff --git a/Sources/MistKit/Service/AssetUploadToken.swift b/Sources/MistKit/Models/AssetUploading/AssetUploadToken.swift similarity index 92% rename from Sources/MistKit/Service/AssetUploadToken.swift rename to Sources/MistKit/Models/AssetUploading/AssetUploadToken.swift index 473d612f..9fb23eda 100644 --- a/Sources/MistKit/Service/AssetUploadToken.swift +++ b/Sources/MistKit/Models/AssetUploading/AssetUploadToken.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,7 @@ // public import Foundation +internal import MistKitOpenAPI /// Token returned after uploading an asset /// diff --git a/Sources/MistKit/Core/AssetUploader.swift b/Sources/MistKit/Models/AssetUploading/AssetUploader.swift similarity index 94% rename from Sources/MistKit/Core/AssetUploader.swift rename to Sources/MistKit/Models/AssetUploading/AssetUploader.swift index 657f95d5..0be708a9 100644 --- a/Sources/MistKit/Core/AssetUploader.swift +++ b/Sources/MistKit/Models/AssetUploading/AssetUploader.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Extensions/URLRequest+AssetUpload.swift b/Sources/MistKit/Models/AssetUploading/URLRequest+AssetUpload.swift similarity index 90% rename from Sources/MistKit/Extensions/URLRequest+AssetUpload.swift rename to Sources/MistKit/Models/AssetUploading/URLRequest+AssetUpload.swift index 5f9997e3..38d079ff 100644 --- a/Sources/MistKit/Extensions/URLRequest+AssetUpload.swift +++ b/Sources/MistKit/Models/AssetUploading/URLRequest+AssetUpload.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation #if canImport(FoundationNetworking) public import FoundationNetworking diff --git a/Sources/MistKit/Extensions/URLSession+AssetUpload.swift b/Sources/MistKit/Models/AssetUploading/URLSession+AssetUpload.swift similarity index 91% rename from Sources/MistKit/Extensions/URLSession+AssetUpload.swift rename to Sources/MistKit/Models/AssetUploading/URLSession+AssetUpload.swift index 2103ff4c..ddcbbe25 100644 --- a/Sources/MistKit/Extensions/URLSession+AssetUpload.swift +++ b/Sources/MistKit/Models/AssetUploading/URLSession+AssetUpload.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -34,6 +34,7 @@ public import Foundation #endif #if !os(WASI) + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension URLSession { /// Upload asset data directly to CloudKit CDN /// diff --git a/Sources/MistKit/Models/BatchSyncResult.swift b/Sources/MistKit/Models/BatchSyncResult.swift new file mode 100644 index 00000000..03fec1fc --- /dev/null +++ b/Sources/MistKit/Models/BatchSyncResult.swift @@ -0,0 +1,142 @@ +// +// BatchSyncResult.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Categorized result of a tracked `modifyRecords(_:classification:atomic:)` call. +/// +/// Returned by `CloudKitService.modifyRecords(_:classification:atomic:)`, +/// this struct partitions the records returned by CloudKit into four groups +/// based on the supplied `OperationClassification`: +/// +/// - `created`: results whose record name was classified as a create +/// - `updated`: results whose record name was classified as an update +/// - `failed`: results that came back as errors (`RecordInfo.isError == true`) +/// - `unclassified`: successful results whose record name was in neither +/// the creates nor updates sets — for example, anonymous creates where +/// CloudKit assigned the record name server-side, or records whose name +/// was not included in the classification +/// +/// Use the `*Count` properties to drive sync summaries and audit logs. +public struct BatchSyncResult: Sendable { + /// Records classified as newly created. + public let created: [RecordInfo] + + /// Records classified as updates to existing records. + public let updated: [RecordInfo] + + /// Records that came back as errors. + public let failed: [RecordInfo] + + /// Successful records that could not be classified as either a create or update. + /// + /// Typically contains anonymous creates where CloudKit assigned the record + /// name server-side, since their names won't appear in either set of the + /// supplied `OperationClassification`. + public let unclassified: [RecordInfo] + + /// Number of records classified as created. + public var createdCount: Int { created.count } + + /// Number of records classified as updated. + public var updatedCount: Int { updated.count } + + /// Number of records that returned an error. + public var failedCount: Int { failed.count } + + /// Number of successful records that could not be classified. + public var unclassifiedCount: Int { unclassified.count } + + /// Total number of records returned by CloudKit, across all categories. + public var totalCount: Int { + created.count + updated.count + failed.count + unclassified.count + } + + /// Number of records that completed successfully (created + updated + unclassified). + public var succeededCount: Int { + created.count + updated.count + unclassified.count + } + + /// Build a `BatchSyncResult` directly from category arrays. + /// + /// Prefer `init(records:classification:)` in production code; this + /// initializer is intended for tests and manual construction. + internal init( + created: [RecordInfo], + updated: [RecordInfo], + failed: [RecordInfo], + unclassified: [RecordInfo] = [] + ) { + self.created = created + self.updated = updated + self.failed = failed + self.unclassified = unclassified + } + + /// Partition a flat array of `RecordInfo` results into a `BatchSyncResult` + /// using a pre-computed classification. + /// + /// Each record is sorted as follows: + /// 1. If `record.isError` is `true`, it is added to `failed`. + /// 2. Else if `record.recordName` is in `classification.creates`, it is added + /// to `created`. + /// 3. Else if `record.recordName` is in `classification.updates`, it is added + /// to `updated`. + /// 4. Otherwise it is added to `unclassified`. + /// + /// - Parameters: + /// - records: The records returned by `modifyRecords`. + /// - classification: The classification used to partition the records. + internal init( + records: [RecordInfo], + classification: OperationClassification + ) { + var created: [RecordInfo] = [] + var updated: [RecordInfo] = [] + var failed: [RecordInfo] = [] + var unclassified: [RecordInfo] = [] + + for record in records { + if record.isError { + failed.append(record) + } else if classification.creates.contains(record.recordName) { + created.append(record) + } else if classification.updates.contains(record.recordName) { + updated.append(record) + } else { + unclassified.append(record) + } + } + + self.created = created + self.updated = updated + self.failed = failed + self.unclassified = unclassified + } +} diff --git a/Sources/MistKit/Models/Database.swift b/Sources/MistKit/Models/Database.swift new file mode 100644 index 00000000..b357a819 --- /dev/null +++ b/Sources/MistKit/Models/Database.swift @@ -0,0 +1,62 @@ +// +// Database.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// CloudKit database scope plus, for `.public`, the per-call attribution +/// choice between server-to-server signing and web-auth signing. +/// +/// The auth payload is part of `.public` rather than a separate parameter +/// because it only matters there — CloudKit rejects server-to-server signing +/// on `.private` and `.shared`, so those cases carry no payload. Encoding +/// the choice in the type means call sites either pick one explicitly +/// (`Database.public(.requires(.webAuth))`) or use a scope where the choice +/// doesn't exist (`Database.private`). +public enum Database: Sendable, Hashable { + /// Public database. Caller must pick a signing method via + /// `PublicAuthPreference`. + case `public`(PublicAuthPreference) + + /// Private database. Web-auth is the only valid signing method. + case `private` + + /// Shared database. Web-auth is the only valid signing method. + case shared + + /// The path segment used to build CloudKit Web Services URLs + /// (`/database/{version}/{container}/{environment}/{database}/…`). + public var pathSegment: String { + switch self { + case .public: + return "public" + case .private: + return "private" + case .shared: + return "shared" + } + } +} diff --git a/Sources/MistKit/Models/Environment.swift b/Sources/MistKit/Models/Environment.swift new file mode 100644 index 00000000..f4198709 --- /dev/null +++ b/Sources/MistKit/Models/Environment.swift @@ -0,0 +1,43 @@ +// +// Environment.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// CloudKit environment types +public enum Environment: String, Sendable { + case development + case production + + /// Initialize from a string by matching the raw value + /// case-insensitively. Returns `nil` if the input does not match + /// one of the canonical raw values (`"development"`, `"production"`). + public init?(caseInsensitive raw: String) { + self.init(rawValue: raw.lowercased()) + } +} diff --git a/Sources/MistKit/Models/FieldValues/Asset.swift b/Sources/MistKit/Models/FieldValues/Asset.swift new file mode 100644 index 00000000..e88f822b --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/Asset.swift @@ -0,0 +1,61 @@ +// +// Asset.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Asset dictionary as defined in CloudKit Web Services +public struct Asset: Codable, Equatable, Sendable { + /// The file checksum + public let fileChecksum: String? + /// The file size in bytes + public let size: Int64? + /// The reference checksum + public let referenceChecksum: String? + /// The wrapping key for encryption + public let wrappingKey: String? + /// The upload receipt + public let receipt: String? + /// The download URL + public let downloadURL: String? + + /// Initialize an asset value + public init( + fileChecksum: String? = nil, + size: Int64? = nil, + referenceChecksum: String? = nil, + wrappingKey: String? = nil, + receipt: String? = nil, + downloadURL: String? = nil + ) { + self.fileChecksum = fileChecksum + self.size = size + self.referenceChecksum = referenceChecksum + self.wrappingKey = wrappingKey + self.receipt = receipt + self.downloadURL = downloadURL + } +} diff --git a/Sources/MistKit/FieldValue+Codable.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift similarity index 96% rename from Sources/MistKit/FieldValue+Codable.swift rename to Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift index 148475a9..24627740 100644 --- a/Sources/MistKit/FieldValue+Codable.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation // MARK: - Codable diff --git a/Sources/MistKit/Service/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift similarity index 97% rename from Sources/MistKit/Service/FieldValue+Components.swift rename to Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index 7ad6b10a..90298a29 100644 --- a/Sources/MistKit/Service/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,7 @@ // internal import Foundation +internal import MistKitOpenAPI /// Extension to convert OpenAPI Components.Schemas.FieldValueResponse to MistKit FieldValue extension FieldValue { diff --git a/Sources/MistKit/Extensions/FieldValue+Convenience.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift similarity index 97% rename from Sources/MistKit/Extensions/FieldValue+Convenience.swift rename to Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift index 0db12b73..50b54c0e 100644 --- a/Sources/MistKit/Extensions/FieldValue+Convenience.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Models/FieldValues/FieldValue.swift b/Sources/MistKit/Models/FieldValues/FieldValue.swift new file mode 100644 index 00000000..8fadacf6 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/FieldValue.swift @@ -0,0 +1,43 @@ +// +// FieldValue.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Represents a CloudKit field value as defined in the CloudKit Web Services API +public enum FieldValue: Codable, Equatable, Sendable { + case string(String) + case int64(Int) + case double(Double) + case bytes(String) // Base64-encoded string + case date(Date) // Date/time value + case location(Location) + case reference(Reference) + case asset(Asset) + case list([FieldValue]) +} diff --git a/Sources/MistKit/Models/FieldValues/Location.swift b/Sources/MistKit/Models/FieldValues/Location.swift new file mode 100644 index 00000000..dfd162d9 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/Location.swift @@ -0,0 +1,71 @@ +// +// Location.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Location dictionary as defined in CloudKit Web Services +public struct Location: Codable, Equatable, Sendable { + /// The latitude coordinate + public let latitude: Double + /// The longitude coordinate + public let longitude: Double + /// The horizontal accuracy in meters + public let horizontalAccuracy: Double? + /// The vertical accuracy in meters + public let verticalAccuracy: Double? + /// The altitude in meters + public let altitude: Double? + /// The speed in meters per second + public let speed: Double? + /// The course in degrees + public let course: Double? + /// The timestamp when location was recorded + public let timestamp: Date? + + /// Initialize a location value + public init( + latitude: Double, + longitude: Double, + horizontalAccuracy: Double? = nil, + verticalAccuracy: Double? = nil, + altitude: Double? = nil, + speed: Double? = nil, + course: Double? = nil, + timestamp: Date? = nil + ) { + self.latitude = latitude + self.longitude = longitude + self.horizontalAccuracy = horizontalAccuracy + self.verticalAccuracy = verticalAccuracy + self.altitude = altitude + self.speed = speed + self.course = course + self.timestamp = timestamp + } +} diff --git a/Sources/MistKit/Models/FieldValues/Reference.swift b/Sources/MistKit/Models/FieldValues/Reference.swift new file mode 100644 index 00000000..648ee924 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/Reference.swift @@ -0,0 +1,48 @@ +// +// Reference.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Reference dictionary as defined in CloudKit Web Services +public struct Reference: Codable, Equatable, Sendable { + /// Reference action types supported by CloudKit + public enum Action: String, Codable, Sendable { + case deleteSelf = "DELETE_SELF" + case none = "NONE" + } + + /// The record name being referenced + public let recordName: String + /// The action to take (DELETE_SELF, NONE, or nil) + public let action: Action? + + /// Initialize a reference value + public init(recordName: String, action: Action? = nil) { + self.recordName = recordName + self.action = action + } +} diff --git a/Sources/MistKit/Models/OperationClassification.swift b/Sources/MistKit/Models/OperationClassification.swift new file mode 100644 index 00000000..2c4f7fd8 --- /dev/null +++ b/Sources/MistKit/Models/OperationClassification.swift @@ -0,0 +1,120 @@ +// +// OperationClassification.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Classifies CloudKit record operations as creates or updates. +/// +/// CloudKit's `/records/modify` endpoint does not indicate in its response +/// whether each operation resulted in a newly-created record or an update to +/// an existing one. The proven workaround is to query the existing record +/// names for a record type before issuing the modify, then partition each +/// proposed operation by whether its record name was already present. +/// +/// `OperationClassification` captures the result of that partitioning so that +/// `CloudKitService.modifyRecords(_:classification:atomic:)` can attribute +/// each returned `RecordInfo` to a create or an update. +/// +/// ## Example +/// ```swift +/// let existing = try await service.fetchExistingRecordNames(recordType: "Article") +/// let classification = OperationClassification( +/// operations: operations, +/// existingRecordNames: existing +/// ) +/// let result = try await service.modifyRecords( +/// operations, +/// classification: classification +/// ) +/// print("Created: \(result.createdCount), Updated: \(result.updatedCount)") +/// ``` +public struct OperationClassification: Sendable, Equatable { + /// Record names that are expected to be created (not present in CloudKit). + public let creates: Set + + /// Record names that are expected to be updated (already present in CloudKit). + public let updates: Set + + /// Build a classification by comparing proposed record names against existing ones. + /// + /// Operations whose record name is in `existingRecordNames` are classified as + /// updates; the rest are classified as creates. Duplicate names in + /// `proposedRecordNames` are folded into the same set entry. + /// + /// - Parameters: + /// - proposedRecordNames: Record names that will be sent to CloudKit. + /// - existingRecordNames: Record names already present in CloudKit + /// (typically obtained via `fetchExistingRecordNames(recordType:)`). + public init( + proposedRecordNames: [String], + existingRecordNames: Set + ) { + var creates = Set() + var updates = Set() + + for recordName in proposedRecordNames { + if existingRecordNames.contains(recordName) { + updates.insert(recordName) + } else { + creates.insert(recordName) + } + } + + self.creates = creates + self.updates = updates + } + + /// Build a classification directly from a sequence of `RecordOperation` values. + /// + /// Operations without a `recordName` (anonymous creates where CloudKit will + /// assign the name) are skipped — they cannot be matched against existing + /// names by definition. + /// + /// - Parameters: + /// - operations: The record operations that will be sent to CloudKit. + /// - existingRecordNames: Record names already present in CloudKit. + public init( + operations: [RecordOperation], + existingRecordNames: Set + ) { + let proposedNames = operations.compactMap(\.recordName) + self.init( + proposedRecordNames: proposedNames, + existingRecordNames: existingRecordNames + ) + } + + /// Direct initializer for tests and manual construction. + /// + /// Prefer the comparison-based initializers in production code. + internal init(creates: Set, updates: Set) { + self.creates = creates + self.updates = updates + } +} diff --git a/Sources/MistKit/Helpers/FilterBuilder+ListMemberFilters.swift b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+ListMemberFilters.swift similarity index 93% rename from Sources/MistKit/Helpers/FilterBuilder+ListMemberFilters.swift rename to Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+ListMemberFilters.swift index 04852edb..2de68fbd 100644 --- a/Sources/MistKit/Helpers/FilterBuilder+ListMemberFilters.swift +++ b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+ListMemberFilters.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,8 +28,8 @@ // import Foundation +internal import MistKitOpenAPI -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension FilterBuilder { // MARK: List Member Filters diff --git a/Sources/MistKit/Helpers/FilterBuilder+StringFilters.swift b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+StringFilters.swift similarity index 92% rename from Sources/MistKit/Helpers/FilterBuilder+StringFilters.swift rename to Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+StringFilters.swift index 9c0dfa6c..4ba70994 100644 --- a/Sources/MistKit/Helpers/FilterBuilder+StringFilters.swift +++ b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+StringFilters.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,8 +28,8 @@ // import Foundation +internal import MistKitOpenAPI -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension FilterBuilder { // MARK: String Filters diff --git a/Sources/MistKit/Helpers/FilterBuilder.swift b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift similarity index 96% rename from Sources/MistKit/Helpers/FilterBuilder.swift rename to Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift index 6948be64..54a569ce 100644 --- a/Sources/MistKit/Helpers/FilterBuilder.swift +++ b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,9 +28,9 @@ // import Foundation +internal import MistKitOpenAPI /// A builder for constructing CloudKit query filters -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal struct FilterBuilder { // MARK: - Lifecycle diff --git a/Sources/MistKit/PublicTypes/QueryFilter.swift b/Sources/MistKit/Models/Queries/QueryFilter.swift similarity index 95% rename from Sources/MistKit/PublicTypes/QueryFilter.swift rename to Sources/MistKit/Models/Queries/QueryFilter.swift index 2222697f..e6669451 100644 --- a/Sources/MistKit/PublicTypes/QueryFilter.swift +++ b/Sources/MistKit/Models/Queries/QueryFilter.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,10 +28,10 @@ // import Foundation +internal import MistKitOpenAPI /// Public wrapper for CloudKit query filters -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct QueryFilter { +public struct QueryFilter: Sendable { // MARK: - Internal internal let filter: Components.Schemas.Filter diff --git a/Sources/MistKit/Models/Queries/QueryResult.swift b/Sources/MistKit/Models/Queries/QueryResult.swift new file mode 100644 index 00000000..d7c54f7f --- /dev/null +++ b/Sources/MistKit/Models/Queries/QueryResult.swift @@ -0,0 +1,55 @@ +// +// QueryResult.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// Result from querying records. +/// +/// Contains the matching records along with an optional continuation marker +/// for fetching the next page of results. +public struct QueryResult: Codable, Sendable { + /// Records matching the query + public let records: [RecordInfo] + /// Marker to pass into the next query request to fetch the next page + public let continuationMarker: String? + + /// Initialize a query result + public init( + records: [RecordInfo], + continuationMarker: String? + ) { + self.records = records + self.continuationMarker = continuationMarker + } + + internal init(from response: Components.Schemas.QueryResponse) { + self.records = response.records?.compactMap { RecordInfo(from: $0) } ?? [] + self.continuationMarker = response.continuationMarker + } +} diff --git a/Sources/MistKit/PublicTypes/QuerySort.swift b/Sources/MistKit/Models/Queries/QuerySort.swift similarity index 84% rename from Sources/MistKit/PublicTypes/QuerySort.swift rename to Sources/MistKit/Models/Queries/QuerySort.swift index bc91cc08..cf055cff 100644 --- a/Sources/MistKit/PublicTypes/QuerySort.swift +++ b/Sources/MistKit/Models/Queries/QuerySort.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,10 +28,10 @@ // import Foundation +internal import MistKitOpenAPI /// Public wrapper for CloudKit query sort descriptors -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct QuerySort { +public struct QuerySort: Sendable { // MARK: - Internal internal let sort: Components.Schemas.Sort @@ -48,14 +48,14 @@ public struct QuerySort { /// - Parameter field: The field name to sort by /// - Returns: A configured QuerySort public static func ascending(_ field: String) -> QuerySort { - QuerySort(SortDescriptor.ascending(field)) + QuerySort(.ascending(field)) } /// Creates a descending sort descriptor /// - Parameter field: The field name to sort by /// - Returns: A configured QuerySort public static func descending(_ field: String) -> QuerySort { - QuerySort(SortDescriptor.descending(field)) + QuerySort(.descending(field)) } /// Creates a sort descriptor with explicit direction @@ -64,6 +64,6 @@ public struct QuerySort { /// - ascending: Whether to sort in ascending order /// - Returns: A configured QuerySort public static func sort(_ field: String, ascending: Bool = true) -> QuerySort { - QuerySort(SortDescriptor.sort(field, ascending: ascending)) + QuerySort(.sort(field, ascending: ascending)) } } diff --git a/Sources/MistKit/Service/RecordChangesResult.swift b/Sources/MistKit/Models/RecordChangesResult.swift similarity index 91% rename from Sources/MistKit/Service/RecordChangesResult.swift rename to Sources/MistKit/Models/RecordChangesResult.swift index 06db59f6..1296b0dc 100644 --- a/Sources/MistKit/Service/RecordChangesResult.swift +++ b/Sources/MistKit/Models/RecordChangesResult.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// Result from fetching record changes +internal import MistKitOpenAPI + +/// Result from fetching record changes. /// /// Contains records that have changed since the provided sync token, /// along with a new sync token for subsequent fetches. diff --git a/Sources/MistKit/Service/RecordInfo.swift b/Sources/MistKit/Models/RecordInfo.swift similarity index 92% rename from Sources/MistKit/Service/RecordInfo.swift rename to Sources/MistKit/Models/RecordInfo.swift index 246b0f1e..aa5da904 100644 --- a/Sources/MistKit/Service/RecordInfo.swift +++ b/Sources/MistKit/Models/RecordInfo.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,7 @@ // internal import Foundation +internal import MistKitOpenAPI /// Record information from CloudKit /// @@ -101,6 +102,9 @@ public struct RecordInfo: Codable, Sendable { /// - recordType: The CloudKit record type /// - recordChangeTag: Optional change tag for optimistic locking /// - fields: Dictionary of field names to their values + /// - created: Optional timestamp when the record was created + /// - modified: Optional timestamp when the record was last modified + /// - deleted: Whether the record has been deleted public init( recordName: String, recordType: String, diff --git a/Sources/MistKit/RecordOperation.swift b/Sources/MistKit/Models/RecordOperation.swift similarity index 96% rename from Sources/MistKit/RecordOperation.swift rename to Sources/MistKit/Models/RecordOperation.swift index d4100b47..5424f903 100644 --- a/Sources/MistKit/RecordOperation.swift +++ b/Sources/MistKit/Models/RecordOperation.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Service/RecordTimestamp.swift b/Sources/MistKit/Models/RecordTimestamp.swift similarity index 89% rename from Sources/MistKit/Service/RecordTimestamp.swift rename to Sources/MistKit/Models/RecordTimestamp.swift index 47d130f8..085d3d88 100644 --- a/Sources/MistKit/Service/RecordTimestamp.swift +++ b/Sources/MistKit/Models/RecordTimestamp.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -28,6 +28,8 @@ // public import Foundation +internal import Logging +internal import MistKitOpenAPI /// Timestamp information for record creation or modification public struct RecordTimestamp: Codable, Sendable { @@ -39,10 +41,8 @@ public struct RecordTimestamp: Codable, Sendable { internal init(from schema: Components.Schemas.RecordTimestamp) { self.timestamp = schema.timestamp.flatMap { millis in guard millis >= 0 else { - MistKitLogger.logWarning( - "Invalid negative timestamp (\(millis) ms) — returning nil", - logger: MistKitLogger.api, - shouldRedact: false + Logger(subsystem: .api).warning( + "Invalid negative timestamp (\(millis) ms) — returning nil" ) return nil } diff --git a/Sources/MistKit/Service/NameComponents.swift b/Sources/MistKit/Models/Users/NameComponents.swift similarity index 94% rename from Sources/MistKit/Service/NameComponents.swift rename to Sources/MistKit/Models/Users/NameComponents.swift index d104321c..cd3db375 100644 --- a/Sources/MistKit/Service/NameComponents.swift +++ b/Sources/MistKit/Models/Users/NameComponents.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,6 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import MistKitOpenAPI + /// The parts of a user's name from CloudKit user discovery. public struct NameComponents: Codable, Sendable { /// The name prefix (e.g., "Dr.", "Mr.") diff --git a/Sources/MistKit/Service/UserIdentity.swift b/Sources/MistKit/Models/Users/UserIdentity.swift similarity index 89% rename from Sources/MistKit/Service/UserIdentity.swift rename to Sources/MistKit/Models/Users/UserIdentity.swift index ccd56f94..ba5c2713 100644 --- a/Sources/MistKit/Service/UserIdentity.swift +++ b/Sources/MistKit/Models/Users/UserIdentity.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// A user identity returned by CloudKit discover endpoints (users/discover, users/caller) +internal import MistKitOpenAPI + +/// A user identity returned by CloudKit discover endpoints (`users/discover`, `users/caller`). public struct UserIdentity: Codable, Sendable { /// The record name of the user in the Users zone public let userRecordName: String? diff --git a/Sources/MistKit/Service/UserIdentityLookupInfo.swift b/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift similarity index 90% rename from Sources/MistKit/Service/UserIdentityLookupInfo.swift rename to Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift index 11b65619..b5852ead 100644 --- a/Sources/MistKit/Service/UserIdentityLookupInfo.swift +++ b/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// Information used to look up a user identity from CloudKit +internal import MistKitOpenAPI + +/// Information used to look up a user identity from CloudKit. public struct UserIdentityLookupInfo: Codable, Sendable { /// The email address to look up public let emailAddress: String? diff --git a/Sources/MistKit/Service/UserInfo.swift b/Sources/MistKit/Models/Users/UserInfo.swift similarity index 89% rename from Sources/MistKit/Service/UserInfo.swift rename to Sources/MistKit/Models/Users/UserInfo.swift index f42533eb..2899f2c3 100644 --- a/Sources/MistKit/Service/UserInfo.swift +++ b/Sources/MistKit/Models/Users/UserInfo.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// User information from CloudKit (User Dictionary — returned by users/current and users/lookup/*) +internal import MistKitOpenAPI + +/// User information from CloudKit (User Dictionary — returned by `users/caller` and `users/lookup/*`). public struct UserInfo: Encodable, Sendable { /// The user's record name public let userRecordName: String diff --git a/Sources/MistKit/Service/ZoneChangesResult.swift b/Sources/MistKit/Models/Zones/ZoneChangesResult.swift similarity index 92% rename from Sources/MistKit/Service/ZoneChangesResult.swift rename to Sources/MistKit/Models/Zones/ZoneChangesResult.swift index 6e629703..cbfd7168 100644 --- a/Sources/MistKit/Service/ZoneChangesResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangesResult.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// Result from fetching zone changes +internal import MistKitOpenAPI + +/// Result from fetching zone changes. /// /// Contains zones that have changed since the provided sync token, /// along with a new sync token for subsequent fetches. diff --git a/Sources/MistKit/Service/ZoneID.swift b/Sources/MistKit/Models/Zones/ZoneID.swift similarity index 91% rename from Sources/MistKit/Service/ZoneID.swift rename to Sources/MistKit/Models/Zones/ZoneID.swift index 758ccca3..2e991c28 100644 --- a/Sources/MistKit/Service/ZoneID.swift +++ b/Sources/MistKit/Models/Zones/ZoneID.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation +internal import MistKitOpenAPI /// Identifies a specific CloudKit zone /// diff --git a/Sources/MistKit/Service/ZoneInfo.swift b/Sources/MistKit/Models/Zones/ZoneInfo.swift similarity index 92% rename from Sources/MistKit/Service/ZoneInfo.swift rename to Sources/MistKit/Models/Zones/ZoneInfo.swift index 3a035871..6e6c6e88 100644 --- a/Sources/MistKit/Service/ZoneInfo.swift +++ b/Sources/MistKit/Models/Zones/ZoneInfo.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Models/Zones/ZoneOperation.swift b/Sources/MistKit/Models/Zones/ZoneOperation.swift new file mode 100644 index 00000000..933af362 --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneOperation.swift @@ -0,0 +1,65 @@ +// +// ZoneOperation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// A create-or-delete operation against a CloudKit zone, used by +/// `CloudKitService.modifyZones(_:database:)`. +public enum ZoneOperation: Sendable, Equatable, Hashable { + /// Create the given zone. + case create(ZoneID) + + /// Delete the given zone. + case delete(ZoneID) + + /// The zone identifier that this operation targets. + public var zoneID: ZoneID { + switch self { + case .create(let zoneID), .delete(let zoneID): + return zoneID + } + } +} + +// MARK: - Internal Conversion +extension Components.Schemas.ZoneOperation { + internal init(from operation: ZoneOperation) { + let operationType: Components.Schemas.ZoneOperation.operationTypePayload + switch operation { + case .create: + operationType = .create + case .delete: + operationType = .delete + } + self.init( + operationType: operationType, + zone: .init(zoneID: Components.Schemas.ZoneID(from: operation.zoneID)) + ) + } +} diff --git a/Sources/MistKit/OpenAPI/CloudKitResponseType.swift b/Sources/MistKit/OpenAPI/CloudKitResponseType.swift new file mode 100644 index 00000000..6a494186 --- /dev/null +++ b/Sources/MistKit/OpenAPI/CloudKitResponseType.swift @@ -0,0 +1,43 @@ +// +// CloudKitResponseType.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Protocol for CloudKit operation response types that support unified error handling. +/// Conformers exhaustively switch over their response cases so a new case in +/// `openapi.yaml` becomes a build error instead of being silently dropped. +/// +/// - Note: The per-operation `Operations.*.Output` conformances in +/// `OpenAPI/Operations/Operations.*.Output.swift` are mechanical, identical +/// except for the type name, and replicate the same status-code-to-case +/// mapping. A future refactor could replace them with an internal attached +/// macro (e.g. `@CloudKitResponse`) that synthesizes `toCloudKitError()` +/// from the response enum's cases, eliminating ~13 boilerplate files. +internal protocol CloudKitResponseType { + /// Returns the `CloudKitError` for this response, or `nil` for `.ok`. + func toCloudKitError() -> CloudKitError? +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Parameters.database.swift b/Sources/MistKit/OpenAPI/Components/Components.Parameters.database.swift new file mode 100644 index 00000000..3031d723 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Parameters.database.swift @@ -0,0 +1,46 @@ +// +// Components.Parameters.database.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Extension to convert MistKit Database to OpenAPI Components.Parameters.database +extension Components.Parameters.database { + /// Initialize from MistKit Database + internal init(from database: Database) { + switch database { + case .public: + self = ._public + case .private: + self = ._private + case .shared: + self = .shared + } + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Parameters.environment.swift b/Sources/MistKit/OpenAPI/Components/Components.Parameters.environment.swift new file mode 100644 index 00000000..80d14587 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Parameters.environment.swift @@ -0,0 +1,44 @@ +// +// Components.Parameters.environment.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Extension to convert MistKit Environment to OpenAPI Components.Parameters.environment +extension Components.Parameters.environment { + /// Initialize from MistKit Environment + internal init(from environment: Environment) { + switch environment { + case .development: + self = .development + case .production: + self = .production + } + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift new file mode 100644 index 00000000..42aa7968 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -0,0 +1,131 @@ +// +// Components.Schemas.FieldValueRequest.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Extension to convert MistKit FieldValue to OpenAPI FieldValueRequest for API requests +extension Components.Schemas.FieldValueRequest { + /// Initialize from MistKit FieldValue for CloudKit API requests. + /// + /// CloudKit infers field types from the value structure, so no type field is sent. + internal init(from fieldValue: FieldValue) { + if let scalar = Self.makeScalarRequest(from: fieldValue) { + self = scalar + } else { + self = Self.makeComplexRequest(from: fieldValue) + } + } + + /// Initialize from Location to Components LocationValue + private init(location: Location) { + let locationValue = Components.Schemas.LocationValue( + latitude: location.latitude, + longitude: location.longitude, + horizontalAccuracy: location.horizontalAccuracy, + verticalAccuracy: location.verticalAccuracy, + altitude: location.altitude, + speed: location.speed, + course: location.course, + timestamp: location.timestamp.map { $0.timeIntervalSince1970 * 1_000 } + ) + self.init(value: .LocationValue(locationValue)) + } + + /// Initialize from Reference to Components ReferenceValue + private init(reference: Reference) { + let action: Components.Schemas.ReferenceValue.actionPayload? + switch reference.action { + case .some(.deleteSelf): + action = .DELETE_SELF + case .some(.none): + action = .NONE + case nil: + action = nil + } + let referenceValue = Components.Schemas.ReferenceValue( + recordName: reference.recordName, + action: action + ) + self.init(value: .ReferenceValue(referenceValue)) + } + + /// Initialize from Asset to Components AssetValue + private init(asset: Asset) { + let assetValue = Components.Schemas.AssetValue( + fileChecksum: asset.fileChecksum, + size: asset.size, + referenceChecksum: asset.referenceChecksum, + wrappingKey: asset.wrappingKey, + receipt: asset.receipt, + downloadURL: asset.downloadURL + ) + self.init(value: .AssetValue(assetValue)) + } + + /// Initialize from List to Components list value + private init(list: [FieldValue]) { + let listValues = list.map { Components.Schemas.ListValuePayload(from: $0) } + self.init(value: .ListValue(listValues)) + } + + private static func makeScalarRequest(from fieldValue: FieldValue) -> Self? { + if case .string(let value) = fieldValue { + return Self(value: .StringValue(value)) + } + if case .int64(let value) = fieldValue { + return Self(value: .Int64Value(Int64(value))) + } + if case .double(let value) = fieldValue { + return Self(value: .DoubleValue(value)) + } + if case .bytes(let value) = fieldValue { + return Self(value: .BytesValue(value)) + } + if case .date(let value) = fieldValue { + return Self(value: .DateValue(value.timeIntervalSince1970 * 1_000)) + } + return nil + } + + private static func makeComplexRequest(from fieldValue: FieldValue) -> Self { + switch fieldValue { + case .location(let location): + return Self(location: location) + case .reference(let reference): + return Self(reference: reference) + case .asset(let asset): + return Self(asset: asset) + case .list(let list): + return Self(list: list) + default: + return Self(value: .ListValue([])) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.Filter.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.Filter.swift new file mode 100644 index 00000000..46c5787c --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.Filter.swift @@ -0,0 +1,39 @@ +// +// Components.Schemas.Filter.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Extension to convert MistKit QueryFilter to OpenAPI Components.Schemas.Filter +extension Components.Schemas.Filter { + /// Initialize from MistKit QueryFilter + internal init(from queryFilter: QueryFilter) { + self = queryFilter.filter + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift new file mode 100644 index 00000000..1fca7871 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift @@ -0,0 +1,121 @@ +// +// Components.Schemas.ListValuePayload.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +extension Components.Schemas.ListValuePayload { + /// Initialize from MistKit FieldValue for list elements + internal init(from fieldValue: FieldValue) { + if let payload = Self.makeScalarPayload(from: fieldValue) { + self = payload + } else { + self = Self.makeComplexPayload(from: fieldValue) + } + } + + private static func makeScalarPayload(from fieldValue: FieldValue) -> Self? { + if case .string(let value) = fieldValue { + return .StringValue(value) + } + if case .int64(let value) = fieldValue { + return .Int64Value(Int64(value)) + } + if case .double(let value) = fieldValue { + return .DoubleValue(value) + } + if case .bytes(let value) = fieldValue { + return .BytesValue(value) + } + if case .date(let value) = fieldValue { + return .DateValue(value.timeIntervalSince1970 * 1_000) + } + return nil + } + + private static func makeComplexPayload(from fieldValue: FieldValue) -> Self { + switch fieldValue { + case .location(let location): + return .LocationValue(makeLocationValue(location)) + case .reference(let reference): + return .ReferenceValue(makeReferenceValue(reference)) + case .asset(let asset): + return .AssetValue(makeAssetValue(asset)) + case .list(let nestedList): + return .ListValue(nestedList.map { Self(from: $0) }) + default: + assertionFailure("Unexpected FieldValue case in makeComplexPayload: \(fieldValue)") + return .ListValue([]) + } + } + + private static func makeLocationValue(_ location: Location) + -> Components.Schemas.LocationValue + { + Components.Schemas.LocationValue( + latitude: location.latitude, + longitude: location.longitude, + horizontalAccuracy: location.horizontalAccuracy, + verticalAccuracy: location.verticalAccuracy, + altitude: location.altitude, + speed: location.speed, + course: location.course, + timestamp: location.timestamp.map { $0.timeIntervalSince1970 * 1_000 } + ) + } + + private static func makeReferenceValue(_ reference: Reference) + -> Components.Schemas.ReferenceValue + { + let action: Components.Schemas.ReferenceValue.actionPayload? + switch reference.action { + case .some(.deleteSelf): + action = .DELETE_SELF + case .some(.none): + action = .NONE + case nil: + action = nil + } + return Components.Schemas.ReferenceValue( + recordName: reference.recordName, + action: action + ) + } + + private static func makeAssetValue(_ asset: Asset) -> Components.Schemas.AssetValue { + Components.Schemas.AssetValue( + fileChecksum: asset.fileChecksum, + size: asset.size, + referenceChecksum: asset.referenceChecksum, + wrappingKey: asset.wrappingKey, + receipt: asset.receipt, + downloadURL: asset.downloadURL + ) + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift new file mode 100644 index 00000000..8ee61414 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift @@ -0,0 +1,71 @@ +// +// Components.Schemas.RecordOperation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Extension to convert MistKit RecordOperation to OpenAPI Components.Schemas.RecordOperation +extension Components.Schemas.RecordOperation { + /// Mapping from RecordOperation.OperationType to OpenAPI operationTypePayload + private static let operationTypeMapping: + [RecordOperation.OperationType: Components.Schemas.RecordOperation.operationTypePayload] = [ + .create: .create, + .update: .update, + .forceUpdate: .forceUpdate, + .replace: .replace, + .forceReplace: .forceReplace, + .delete: .delete, + .forceDelete: .forceDelete, + ] + + /// Initialize from MistKit RecordOperation + internal init(from recordOperation: RecordOperation) throws(CloudKitError) { + // Convert operation type using dictionary lookup + guard let apiOperationType = Self.operationTypeMapping[recordOperation.operationType] else { + throw CloudKitError.unsupportedOperationType("\(recordOperation.operationType)") + } + + // Convert fields to OpenAPI FieldValueRequest format (for requests) + let apiFields = recordOperation.fields.mapValues { + fieldValue -> Components.Schemas.FieldValueRequest in + Components.Schemas.FieldValueRequest(from: fieldValue) + } + + // Build the OpenAPI record operation + self.init( + operationType: apiOperationType, + record: .init( + recordName: recordOperation.recordName, + recordType: recordOperation.recordType, + recordChangeTag: recordOperation.recordChangeTag, + fields: .init(additionalProperties: apiFields) + ) + ) + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.Sort.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.Sort.swift new file mode 100644 index 00000000..9c8dd62b --- /dev/null +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.Sort.swift @@ -0,0 +1,62 @@ +// +// Components.Schemas.Sort.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Extension to convert MistKit QuerySort to OpenAPI Components.Schemas.Sort +extension Components.Schemas.Sort { + /// Initialize from MistKit QuerySort + internal init(from querySort: QuerySort) { + self = querySort.sort + } + + /// Creates an ascending sort descriptor + /// - Parameter field: The field name to sort by + /// - Returns: A configured Sort + internal static func ascending(_ field: String) -> Self { + .init(fieldName: field, ascending: true) + } + + /// Creates a descending sort descriptor + /// - Parameter field: The field name to sort by + /// - Returns: A configured Sort + internal static func descending(_ field: String) -> Self { + .init(fieldName: field, ascending: false) + } + + /// Creates a sort descriptor with explicit direction + /// - Parameters: + /// - field: The field name to sort by + /// - ascending: Whether to sort in ascending order + /// - Returns: A configured Sort + internal static func sort(_ field: String, ascending: Bool = true) -> Self { + .init(fieldName: field, ascending: ascending) + } +} diff --git a/Sources/MistKit/OpenAPI/LoggingMiddleware.swift b/Sources/MistKit/OpenAPI/LoggingMiddleware.swift new file mode 100644 index 00000000..7c6db8f9 --- /dev/null +++ b/Sources/MistKit/OpenAPI/LoggingMiddleware.swift @@ -0,0 +1,126 @@ +// +// LoggingMiddleware.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +internal import Logging +import OpenAPIRuntime + +/// Logging middleware for HTTP request/response tracing. +/// +/// Emits at `.debug` level — install a `LogHandler` and set +/// `logLevel = .debug` on `com.brightdigit.MistKit.middleware` to opt in. +internal struct LoggingMiddleware: ClientMiddleware { + private let logger = Logger(subsystem: .middleware) + + internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + logRequest(request, baseURL: baseURL) + let (response, responseBody) = try await next(request, body, baseURL) + let finalResponseBody = await logResponse(response, body: responseBody) + return (response, finalResponseBody) + } + + private func logRequest(_ request: HTTPRequest, baseURL: URL) { + let fullPath = baseURL.absoluteString + (request.path ?? "") + logger.debug("🌐 CloudKit Request: \(request.method.rawValue) \(fullPath)") + logger.debug(" Base URL: \(baseURL.absoluteString)") + logger.debug(" Path: \(request.path ?? "none")") + logger.debug(" Headers: \(request.headerFields)") + + logQueryParameters(for: request, baseURL: baseURL) + } + + private func logQueryParameters(for request: HTTPRequest, baseURL: URL) { + guard logger.logLevel <= .debug, + let path = request.path, + let url = URL(string: path, relativeTo: baseURL), + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems + else { + return + } + + logger.debug(" Query Parameters:") + for item in queryItems { + logger.debug(" \(item.name): \(item.value ?? "nil")") + } + } + + private func logResponse(_ response: HTTPResponse, body: HTTPBody?) async -> HTTPBody? { + logger.debug("✅ CloudKit Response: \(response.status.code)") + + if response.status.code == 421 { + logger.warning( + "⚠️ 421 Misdirected Request - The server cannot produce a response for this request" + ) + } + + guard logger.logLevel <= .debug else { + return body + } + + #if !os(WASI) + return await logResponseBody(body) + #else + return body + #endif + } + + #if !os(WASI) + private func logResponseBody(_ responseBody: HTTPBody?) async -> HTTPBody? { + guard let responseBody = responseBody else { + return nil + } + + do { + let bodyData = try await Data(collecting: responseBody, upTo: 1_024 * 1_024) + logBodyData(bodyData) + return HTTPBody(bodyData) + } catch { + logger.error("📄 Response Body: ") + return responseBody + } + } + + private func logBodyData(_ bodyData: Data) { + if let jsonString = String(data: bodyData, encoding: .utf8) { + logger.debug("📄 Response Body:") + logger.debug("\(jsonString)") + } else { + logger.debug("📄 Response Body: ") + } + } + #endif +} diff --git a/Sources/MistKit/OpenAPI/OperationInputPath.swift b/Sources/MistKit/OpenAPI/OperationInputPath.swift new file mode 100644 index 00000000..b0a9b7f9 --- /dev/null +++ b/Sources/MistKit/OpenAPI/OperationInputPath.swift @@ -0,0 +1,88 @@ +// +// OperationInputPath.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +/// Shared shape of every generated `Operations.*.Input.Path` type. +/// +/// All CloudKit Web Services endpoints share the same path template +/// (`/database/{version}/{container}/{environment}/{database}/...`), +/// so each generated `Input.Path` exposes the same memberwise initializer. +/// Conforming each one to this protocol unlocks a single MistKit-flavored +/// convenience init that takes the domain `Environment` and `Database` +/// directly. +internal protocol OperationInputPath { + init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) +} + +extension OperationInputPath { + /// Initialize from MistKit configuration components. + internal init( + containerIdentifier: String, + environment: Environment, + database: Database + ) { + self.init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } +} + +extension Operations.discoverUserIdentities.Input.Path: OperationInputPath {} + +extension Operations.fetchRecordChanges.Input.Path: OperationInputPath {} + +extension Operations.fetchZoneChanges.Input.Path: OperationInputPath {} + +extension Operations.getCaller.Input.Path: OperationInputPath {} + +extension Operations.listZones.Input.Path: OperationInputPath {} + +extension Operations.lookupRecords.Input.Path: OperationInputPath {} + +extension Operations.lookupUsersByEmail.Input.Path: OperationInputPath {} + +extension Operations.lookupUsersByRecordName.Input.Path: OperationInputPath {} + +extension Operations.lookupZones.Input.Path: OperationInputPath {} + +extension Operations.modifyZones.Input.Path: OperationInputPath {} + +extension Operations.queryRecords.Input.Path: OperationInputPath {} + +extension Operations.uploadAssets.Input.Path: OperationInputPath {} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.discoverUserIdentities.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.discoverUserIdentities.Output.swift new file mode 100644 index 00000000..6027de93 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.discoverUserIdentities.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.discoverUserIdentities.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.discoverUserIdentities.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.fetchRecordChanges.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.fetchRecordChanges.Output.swift new file mode 100644 index 00000000..e2cf87cc --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.fetchRecordChanges.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.fetchRecordChanges.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.fetchRecordChanges.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.fetchZoneChanges.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.fetchZoneChanges.Output.swift new file mode 100644 index 00000000..f4b7488c --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.fetchZoneChanges.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.fetchZoneChanges.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.fetchZoneChanges.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.getCaller.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.getCaller.Output.swift new file mode 100644 index 00000000..78f0d8c1 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.getCaller.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.getCaller.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.getCaller.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.listZones.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.listZones.Output.swift new file mode 100644 index 00000000..be0350d7 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.listZones.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.listZones.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.listZones.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.lookupRecords.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.lookupRecords.Output.swift new file mode 100644 index 00000000..a80b5585 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.lookupRecords.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.lookupRecords.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.lookupRecords.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.lookupUsersByEmail.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.lookupUsersByEmail.Output.swift new file mode 100644 index 00000000..70a020e9 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.lookupUsersByEmail.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.lookupUsersByEmail.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.lookupUsersByEmail.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.lookupUsersByRecordName.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.lookupUsersByRecordName.Output.swift new file mode 100644 index 00000000..0b26bba6 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.lookupUsersByRecordName.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.lookupUsersByRecordName.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.lookupUsersByRecordName.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.lookupZones.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.lookupZones.Output.swift new file mode 100644 index 00000000..f8993252 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.lookupZones.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.lookupZones.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.lookupZones.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.modifyRecords.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.modifyRecords.Output.swift new file mode 100644 index 00000000..6cd423b1 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.modifyRecords.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.modifyRecords.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.modifyRecords.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.modifyZones.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.modifyZones.Output.swift new file mode 100644 index 00000000..7c3eea31 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.modifyZones.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.modifyZones.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.modifyZones.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.queryRecords.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.queryRecords.Output.swift new file mode 100644 index 00000000..08bf8574 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.queryRecords.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.queryRecords.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.queryRecords.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.uploadAssets.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.uploadAssets.Output.swift new file mode 100644 index 00000000..fd430e3d --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.uploadAssets.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.uploadAssets.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.uploadAssets.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/Protocols/RecordManaging.swift b/Sources/MistKit/Protocols/RecordManaging.swift deleted file mode 100644 index d15f2e63..00000000 --- a/Sources/MistKit/Protocols/RecordManaging.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// RecordManaging.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// Protocol defining core CloudKit record management operations -/// -/// This protocol provides a testable abstraction for CloudKit operations. -/// Conforming types must implement the two core operations, while all other -/// functionality (listing, syncing, deleting) is provided through protocol extensions. -public protocol RecordManaging { - /// Query records of a specific type from CloudKit - /// - /// - Parameter recordType: The CloudKit record type to query - /// - Returns: Array of record information for all matching records - /// - Throws: CloudKit errors if the query fails - func queryRecords(recordType: String) async throws -> [RecordInfo] - - /// Execute a batch of record operations - /// - /// Handles batching operations to respect CloudKit's 200 operations/request limit. - /// Provides detailed progress reporting and error tracking. - /// - /// - Parameters: - /// - operations: Array of record operations to execute - /// - recordType: The record type being operated on (for logging) - /// - Throws: CloudKit errors if the batch operations fail - func executeBatchOperations(_ operations: [RecordOperation], recordType: String) async throws -} diff --git a/Sources/MistKit/Protocols/CloudKitRecord.swift b/Sources/MistKit/RecordManagement/CloudKitRecord.swift similarity index 96% rename from Sources/MistKit/Protocols/CloudKitRecord.swift rename to Sources/MistKit/RecordManagement/CloudKitRecord.swift index e5897d7e..06d02f3c 100644 --- a/Sources/MistKit/Protocols/CloudKitRecord.swift +++ b/Sources/MistKit/RecordManagement/CloudKitRecord.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Protocol for types that can be serialized to and from CloudKit records /// diff --git a/Sources/MistKit/Protocols/CloudKitRecordCollection.swift b/Sources/MistKit/RecordManagement/CloudKitRecordCollection.swift similarity index 94% rename from Sources/MistKit/Protocols/CloudKitRecordCollection.swift rename to Sources/MistKit/RecordManagement/CloudKitRecordCollection.swift index 0c99163f..edb2c1d2 100644 --- a/Sources/MistKit/Protocols/CloudKitRecordCollection.swift +++ b/Sources/MistKit/RecordManagement/CloudKitRecordCollection.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Extensions/RecordManaging+Generic.swift b/Sources/MistKit/RecordManagement/RecordManaging+Generic.swift similarity index 88% rename from Sources/MistKit/Extensions/RecordManaging+Generic.swift rename to Sources/MistKit/RecordManagement/RecordManaging+Generic.swift index 2800582a..e03c96b2 100644 --- a/Sources/MistKit/Extensions/RecordManaging+Generic.swift +++ b/Sources/MistKit/RecordManagement/RecordManaging+Generic.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -61,10 +61,12 @@ extension RecordManaging { } // Batch operations to respect CloudKit's 200 operations/request limit - let batches = operations.chunked(into: 200) + let batches = stride(from: 0, to: operations.count, by: 200).map { + Array(operations[$0..(_ type: T.Type) async throws { - let records = try await queryRecords(recordType: T.cloudKitRecordType) + let records = try await queryAllRecords(recordType: T.cloudKitRecordType) print("\n\(T.cloudKitRecordType) (\(records.count) total)") print(String(repeating: "=", count: 80)) @@ -122,7 +123,7 @@ extension RecordManaging { _ type: T.Type, where filter: (RecordInfo) -> Bool = { _ in true } ) async throws -> [T] { - let records = try await queryRecords(recordType: T.cloudKitRecordType) + let records = try await queryAllRecords(recordType: T.cloudKitRecordType) return records.filter(filter).compactMap(T.from) } } diff --git a/Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift b/Sources/MistKit/RecordManagement/RecordManaging+RecordCollection.swift similarity index 92% rename from Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift rename to Sources/MistKit/RecordManagement/RecordManaging+RecordCollection.swift index 3103e32a..900747cd 100644 --- a/Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift +++ b/Sources/MistKit/RecordManagement/RecordManaging+RecordCollection.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Default implementations for RecordManaging when conforming to CloudKitRecordCollection /// @@ -81,7 +81,7 @@ extension RecordManaging where Self: CloudKitRecordCollection { } // Execute batch operation for this record type - try await executeBatchOperations(operations, recordType: typeName) + try await executeBatchOperations(operations) } } @@ -97,10 +97,11 @@ extension RecordManaging where Self: CloudKitRecordCollection { var recordTypesList: [any CloudKitRecord.Type] = [] // Use RecordTypeSet to iterate through types without reflection + // swift-format-ignore: ReplaceForEachWithForLoop try await Self.recordTypes.forEach { recordType in recordTypesList.append(recordType) let typeName = recordType.cloudKitRecordType - let records = try await queryRecords(recordType: typeName) + let records = try await queryAllRecords(recordType: typeName) countsByType[typeName] = records.count totalCount += records.count @@ -144,9 +145,10 @@ extension RecordManaging where Self: CloudKitRecordCollection { print("\n🗑️ Deleting all records across all types...") // Use RecordTypeSet to iterate through types without reflection + // swift-format-ignore: ReplaceForEachWithForLoop try await Self.recordTypes.forEach { recordType in let typeName = recordType.cloudKitRecordType - let records = try await queryRecords(recordType: typeName) + let records = try await queryAllRecords(recordType: typeName) guard !records.isEmpty else { print("\n\(typeName): No records to delete") @@ -166,7 +168,7 @@ extension RecordManaging where Self: CloudKitRecordCollection { } // Execute batch delete operations - try await executeBatchOperations(operations, recordType: typeName) + try await executeBatchOperations(operations) deletedByType[typeName] = records.count totalDeleted += records.count diff --git a/Sources/MistKit/RecordManagement/RecordManaging.swift b/Sources/MistKit/RecordManagement/RecordManaging.swift new file mode 100644 index 00000000..3620a7f6 --- /dev/null +++ b/Sources/MistKit/RecordManagement/RecordManaging.swift @@ -0,0 +1,78 @@ +// +// RecordManaging.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Protocol defining core CloudKit record management operations +/// +/// This protocol provides a testable abstraction for CloudKit operations. +/// Conforming types must implement the two core operations, while all other +/// functionality (listing, syncing, deleting) is provided through protocol extensions. +public protocol RecordManaging { + /// Query records of a specific type from CloudKit + /// + /// - Parameter recordType: The CloudKit record type to query + /// - Returns: Array of record information for all matching records + /// - Throws: CloudKit errors if the query fails + @available( + *, deprecated, + message: "Silently truncates at one page. Use queryAllRecords or queryRecords -> QueryResult." + ) + func queryRecords(recordType: String) async throws -> [RecordInfo] + + /// Execute a batch of record operations + /// + /// Handles batching operations to respect CloudKit's 200 operations/request limit. + /// Each `RecordOperation` carries its own record type, so no separate + /// `recordType` parameter is required. + /// + /// - Parameter operations: Array of record operations to execute + /// - Throws: CloudKit errors if the batch operations fail + func executeBatchOperations(_ operations: [RecordOperation]) async throws + + /// Query all records of a specific type, automatically paginating + /// + /// - Parameter recordType: The CloudKit record type to query + /// - Returns: Array of all matching records across all pages + /// - Throws: CloudKit errors if the query fails + func queryAllRecords(recordType: String) async throws -> [RecordInfo] +} + +extension RecordManaging { + /// Default implementation delegates to the deprecated `queryRecords(recordType:)`, + /// which only returns one page. Conformers should override this with a real + /// auto-paginating implementation (e.g. `CloudKitService.queryAllRecords`). + @available( + *, deprecated, + message: "Default returns one page. Override with a real auto-paginating implementation." + ) + public func queryAllRecords(recordType: String) async throws -> [RecordInfo] { + try await queryRecords(recordType: recordType) + } +} diff --git a/Sources/MistKit/Protocols/RecordTypeIterating.swift b/Sources/MistKit/RecordManagement/RecordTypeIterating.swift similarity index 92% rename from Sources/MistKit/Protocols/RecordTypeIterating.swift rename to Sources/MistKit/RecordManagement/RecordTypeIterating.swift index ebd4fdcd..796e54c2 100644 --- a/Sources/MistKit/Protocols/RecordTypeIterating.swift +++ b/Sources/MistKit/RecordManagement/RecordTypeIterating.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/MistKit/Protocols/RecordTypeSet.swift b/Sources/MistKit/RecordManagement/RecordTypeSet.swift similarity index 87% rename from Sources/MistKit/Protocols/RecordTypeSet.swift rename to Sources/MistKit/RecordManagement/RecordTypeSet.swift index 33d68848..84040e87 100644 --- a/Sources/MistKit/Protocols/RecordTypeSet.swift +++ b/Sources/MistKit/RecordManagement/RecordTypeSet.swift @@ -7,7 +7,7 @@ // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without +// files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -47,10 +47,8 @@ /// ``` @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) public struct RecordTypeSet: Sendable, RecordTypeIterating { - /// Initialize with a parameter pack of CloudKit record types - /// - /// - Parameter types: Variadic parameter pack of CloudKit record types - public init(_ types: repeat (each RecordType).Type) {} + /// Initialize with a variadic parameter pack of CloudKit record types. + public init(_: repeat (each RecordType).Type) {} /// Iterate through all record types in the parameter pack /// diff --git a/Sources/MistKit/Service/CloudKitError+OpenAPI.swift b/Sources/MistKit/Service/CloudKitError+OpenAPI.swift deleted file mode 100644 index 912e13a3..00000000 --- a/Sources/MistKit/Service/CloudKitError+OpenAPI.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// CloudKitError+OpenAPI.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension CloudKitError { - /// Generic error extractors that work for any CloudKitResponseType - /// Acts as a reusable dictionary mapping response cases to error initializers - private static let errorExtractors: [@Sendable (any CloudKitResponseType) -> CloudKitError?] = [ - { $0.badRequestResponse.map { CloudKitError(badRequest: $0) } }, - { $0.unauthorizedResponse.map { CloudKitError(unauthorized: $0) } }, - { $0.forbiddenResponse.map { CloudKitError(forbidden: $0) } }, - { $0.notFoundResponse.map { CloudKitError(notFound: $0) } }, - { $0.conflictResponse.map { CloudKitError(conflict: $0) } }, - { $0.preconditionFailedResponse.map { CloudKitError(preconditionFailed: $0) } }, - { $0.contentTooLargeResponse.map { CloudKitError(contentTooLarge: $0) } }, - { $0.misdirectedRequestResponse.map { CloudKitError(unprocessableEntity: $0) } }, - { $0.tooManyRequestsResponse.map { CloudKitError(tooManyRequests: $0) } }, - { $0.internalServerErrorResponse.map { CloudKitError(internalServerError: $0) } }, - { $0.serviceUnavailableResponse.map { CloudKitError(serviceUnavailable: $0) } }, - ] - - /// Initialize CloudKitError from a BadRequest response - private init(badRequest response: Components.Responses.BadRequest) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 400, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 400) - } - } - - /// Initialize CloudKitError from an Unauthorized response - private init(unauthorized response: Components.Responses.Unauthorized) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 401, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 401) - } - } - - /// Initialize CloudKitError from a Forbidden response - private init(forbidden response: Components.Responses.Forbidden) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 403, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 403) - } - } - - /// Initialize CloudKitError from a NotFound response - private init(notFound response: Components.Responses.NotFound) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 404, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 404) - } - } - - /// Initialize CloudKitError from a Conflict response - private init(conflict response: Components.Responses.Conflict) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 409, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 409) - } - } - - /// Initialize CloudKitError from a PreconditionFailed response - private init(preconditionFailed response: Components.Responses.PreconditionFailed) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 412, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 412) - } - } - - /// Initialize CloudKitError from a RequestEntityTooLarge response - private init(contentTooLarge response: Components.Responses.RequestEntityTooLarge) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 413, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 413) - } - } - - /// Initialize CloudKitError from a TooManyRequests response - private init(tooManyRequests response: Components.Responses.TooManyRequests) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 429, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 429) - } - } - - /// Initialize CloudKitError from an UnprocessableEntity response - private init(unprocessableEntity response: Components.Responses.UnprocessableEntity) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 422, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 422) - } - } - - /// Initialize CloudKitError from an InternalServerError response - private init(internalServerError response: Components.Responses.InternalServerError) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 500, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 500) - } - } - - /// Initialize CloudKitError from a ServiceUnavailable response - private init(serviceUnavailable response: Components.Responses.ServiceUnavailable) { - if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 503, - serverErrorCode: errorResponse.serverErrorCode?.rawValue, - reason: errorResponse.reason - ) - } else { - self = .httpError(statusCode: 503) - } - } - - /// Generic failable initializer for any CloudKitResponseType - /// Returns nil if the response is .ok (not an error) - internal init?(_ response: T) { - // Check if response is .ok - not an error - if response.isOk { - return nil - } - - // Try each error extractor - for extractor in Self.errorExtractors { - if let error = extractor(response) { - self = error - return - } - } - - // Handle undocumented error - if let statusCode = response.undocumentedStatusCode { - // Log warning but don't crash - undocumented status codes can occur - MistKitLogger.logWarning( - "Unhandled response status code: \(statusCode) - treating as generic HTTP error", - logger: MistKitLogger.api, - shouldRedact: false - ) - self = .httpError(statusCode: statusCode) - return - } - - MistKitLogger.logWarning( - "Unhandled response case: \(response) - treating as invalid response", - logger: MistKitLogger.api, - shouldRedact: false - ) - self = .invalidResponse - } -} diff --git a/Sources/MistKit/Service/CloudKitError.swift b/Sources/MistKit/Service/CloudKitError.swift deleted file mode 100644 index cdf49701..00000000 --- a/Sources/MistKit/Service/CloudKitError.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// CloudKitError.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -/// Represents errors that can occur when interacting with CloudKit Web Services -public enum CloudKitError: LocalizedError, Sendable { - case httpError(statusCode: Int) - case httpErrorWithDetails(statusCode: Int, serverErrorCode: String?, reason: String?) - case httpErrorWithRawResponse(statusCode: Int, rawResponse: String) - case invalidResponse - case underlyingError(any Error) - case decodingError(DecodingError) - case networkError(URLError) - - /// A localized message describing what error occurred - public var errorDescription: String? { - switch self { - case .httpError(let statusCode): - return "CloudKit API error: HTTP \(statusCode)" - case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason): - var message = "CloudKit API error: HTTP \(statusCode)" - if let serverErrorCode = serverErrorCode { - message += "\nServer Error Code: \(serverErrorCode)" - } - if let reason = reason { - message += "\nReason: \(reason)" - } - return message - case .httpErrorWithRawResponse(let statusCode, let rawResponse): - return "CloudKit API error: HTTP \(statusCode)\nRaw Response: \(rawResponse)" - case .invalidResponse: - return "Invalid response from CloudKit" - case .underlyingError(let error): - return "CloudKit operation failed with underlying error: \(String(reflecting: error))" - case .decodingError(let error): - var message = "Failed to decode CloudKit response" - switch error { - case .keyNotFound(let key, let context): - message += "\nMissing key: \(key.stringValue)" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - case .typeMismatch(let type, let context): - message += "\nType mismatch: expected \(type)" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - case .valueNotFound(let type, let context): - message += "\nValue not found: expected \(type)" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - case .dataCorrupted(let context): - message += "\nData corrupted" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - @unknown default: - message += "\nUnknown decoding error: \(error.localizedDescription)" - } - return message - case .networkError(let error): - var message = "Network error occurred" - message += "\nError code: \(error.code.rawValue)" - if let url = error.failureURLString { - message += "\nFailed URL: \(url)" - } - message += "\nDescription: \(error.localizedDescription)" - return message - } - } -} diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift deleted file mode 100644 index eb78318e..00000000 --- a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// CloudKitResponseProcessor+Changes.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation -import OpenAPIRuntime - -extension CloudKitResponseProcessor { - /// Process fetchRecordChanges response - /// - Parameter response: The response to process - /// - Returns: The extracted changes response data - /// - Throws: CloudKitError for various error conditions - internal func processFetchRecordChangesResponse(_ response: Operations.fetchRecordChanges.Output) - async throws(CloudKitError) -> Components.Schemas.ChangesResponse - { - if let error = CloudKitError(response) { - throw error - } - switch response { - case .ok(let okResponse): - switch okResponse.body { - case .json(let changesData): - return changesData - } - default: - assertionFailure("Unexpected response case after error handling") - throw CloudKitError.invalidResponse - } - } - - /// Process discoverUserIdentities response - internal func processDiscoverUserIdentitiesResponse( - _ response: Operations.discoverUserIdentities.Output - ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { - if let error = CloudKitError(response) { - throw error - } - switch response { - case .ok(let okResponse): - switch okResponse.body { - case .json(let discoverData): - return discoverData - } - default: - assertionFailure("Unexpected response case after error handling") - throw CloudKitError.invalidResponse - } - } - - /// Process uploadAssets response - /// - Parameter response: The response to process - /// - Returns: The extracted asset upload response data - /// - Throws: CloudKitError for various error conditions - internal func processUploadAssetsResponse(_ response: Operations.uploadAssets.Output) - async throws(CloudKitError) -> Components.Schemas.AssetUploadResponse - { - if let error = CloudKitError(response) { - throw error - } - switch response { - case .ok(let okResponse): - switch okResponse.body { - case .json(let uploadData): - return uploadData - } - default: - assertionFailure("Unexpected response case after error handling") - throw CloudKitError.invalidResponse - } - } - - /// Process fetchZoneChanges response - internal func processFetchZoneChangesResponse(_ response: Operations.fetchZoneChanges.Output) - async throws(CloudKitError) -> Components.Schemas.ZoneChangesResponse - { - if let error = CloudKitError(response) { - throw error - } - switch response { - case .ok(let okResponse): - switch okResponse.body { - case .json(let changesData): - return changesData - } - default: - assertionFailure("Unexpected response case after error handling") - throw CloudKitError.invalidResponse - } - } -} diff --git a/Sources/MistKit/Service/CloudKitResponseType.swift b/Sources/MistKit/Service/CloudKitResponseType.swift deleted file mode 100644 index 00c512e6..00000000 --- a/Sources/MistKit/Service/CloudKitResponseType.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// CloudKitResponseType.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -/// Protocol for CloudKit operation response types that support unified error handling -internal protocol CloudKitResponseType { - /// Extract BadRequest response if present - var badRequestResponse: Components.Responses.BadRequest? { get } - - /// Extract Unauthorized response if present - var unauthorizedResponse: Components.Responses.Unauthorized? { get } - - /// Extract Forbidden response if present - var forbiddenResponse: Components.Responses.Forbidden? { get } - - /// Extract NotFound response if present - var notFoundResponse: Components.Responses.NotFound? { get } - - /// Extract Conflict response if present - var conflictResponse: Components.Responses.Conflict? { get } - - /// Extract PreconditionFailed response if present - var preconditionFailedResponse: Components.Responses.PreconditionFailed? { get } - - /// Extract ContentTooLarge response if present - var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { get } - - /// Extract MisdirectedRequest response if present - var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { get } - - /// Extract TooManyRequests response if present - var tooManyRequestsResponse: Components.Responses.TooManyRequests? { get } - - /// Extract InternalServerError response if present - var internalServerErrorResponse: Components.Responses.InternalServerError? { get } - - /// Extract ServiceUnavailable response if present - var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { get } - - /// Check if response is successful (.ok case) - var isOk: Bool { get } - - /// Extract status code from undocumented response if present - var undocumentedStatusCode: Int? { get } -} diff --git a/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift b/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift deleted file mode 100644 index dd42899a..00000000 --- a/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// CloudKitService+ErrorHandling.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - /// Maps any error thrown from a CloudKit operation to a typed CloudKitError. - /// Includes detailed logging for decoding and network errors. - /// - /// - Parameters: - /// - error: The error to map - /// - context: A description of the operation (e.g., "fetchCurrentUser") - /// - Returns: A CloudKitError representing the original error - internal func mapToCloudKitError( - _ error: any Error, - context: String - ) -> CloudKitError { - if let cloudKitError = error as? CloudKitError { - return cloudKitError - } - - if let decodingError = error as? DecodingError { - MistKitLogger.logError( - "JSON decoding failed in \(context): \(decodingError)", - logger: MistKitLogger.api, - shouldRedact: false - ) - logDecodingErrorDetails(decodingError) - return CloudKitError.decodingError(decodingError) - } - - if let urlError = error as? URLError { - MistKitLogger.logError( - "Network error in \(context): \(urlError)", - logger: MistKitLogger.network, - shouldRedact: false - ) - return CloudKitError.networkError(urlError) - } - - MistKitLogger.logError( - "Unexpected error in \(context): \(error)", - logger: MistKitLogger.api, - shouldRedact: false - ) - MistKitLogger.logDebug( - "Error type: \(type(of: error)), Description: \(String(reflecting: error))", - logger: MistKitLogger.api, - shouldRedact: false - ) - return CloudKitError.underlyingError(error) - } - - /// Logs detailed context for a DecodingError to aid debugging. - private func logDecodingErrorDetails(_ decodingError: DecodingError) { - switch decodingError { - case .keyNotFound(let key, let context): - MistKitLogger.logDebug( - "Missing key: \(key), Context: \(context.debugDescription), " - + "Coding path: \(context.codingPath)", - logger: MistKitLogger.api, - shouldRedact: false - ) - case .typeMismatch(let type, let context): - MistKitLogger.logDebug( - "Type mismatch: expected \(type), Context: \(context.debugDescription), " - + "Coding path: \(context.codingPath)", - logger: MistKitLogger.api, - shouldRedact: false - ) - case .valueNotFound(let type, let context): - MistKitLogger.logDebug( - "Value not found: expected \(type), Context: \(context.debugDescription), " - + "Coding path: \(context.codingPath)", - logger: MistKitLogger.api, - shouldRedact: false - ) - case .dataCorrupted(let context): - MistKitLogger.logDebug( - "Data corrupted, Context: \(context.debugDescription), " - + "Coding path: \(context.codingPath)", - logger: MistKitLogger.api, - shouldRedact: false - ) - @unknown default: - MistKitLogger.logDebug( - "Unknown decoding error type", - logger: MistKitLogger.api, - shouldRedact: false - ) - } - } -} diff --git a/Sources/MistKit/Service/CloudKitService+Initialization.swift b/Sources/MistKit/Service/CloudKitService+Initialization.swift deleted file mode 100644 index cdec0625..00000000 --- a/Sources/MistKit/Service/CloudKitService+Initialization.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// CloudKitService+Initialization.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -public import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -// MARK: - Generic Initializers (All Platforms) - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - /// Initialize CloudKit service with web authentication - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public init( - containerIdentifier: String, - apiToken: String, - webAuthToken: String, - transport: any ClientTransport - ) throws { - self.containerIdentifier = containerIdentifier - self.apiToken = apiToken - self.environment = .development - self.database = .private - - let config = MistKitConfiguration( - container: containerIdentifier, - environment: .development, - database: .private, - apiToken: apiToken, - webAuthToken: webAuthToken - ) - self.mistKitClient = try MistKitClient( - configuration: config, - transport: transport - ) - } - - /// Initialize CloudKit service with API-only authentication - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public init( - containerIdentifier: String, - apiToken: String, - transport: any ClientTransport - ) throws { - self.containerIdentifier = containerIdentifier - self.apiToken = apiToken - self.environment = .development - self.database = .public // API-only supports public database - - let config = MistKitConfiguration( - container: containerIdentifier, - environment: .development, - database: .public, // API-only supports public database - apiToken: apiToken, - webAuthToken: nil, - keyID: nil, - privateKeyData: nil - ) - self.mistKitClient = try MistKitClient( - configuration: config, - transport: transport - ) - } - - /// Initialize CloudKit service with a custom TokenManager - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public init( - containerIdentifier: String, - tokenManager: any TokenManager, - environment: Environment = .development, - database: Database = .private, - transport: any ClientTransport - ) throws { - self.containerIdentifier = containerIdentifier - self.apiToken = "" // Not used when providing TokenManager directly - self.environment = environment - self.database = database - - self.mistKitClient = try MistKitClient( - container: containerIdentifier, - environment: environment, - database: database, - tokenManager: tokenManager, - transport: transport - ) - } -} - -// MARK: - URLSession Convenience Initializers (Non-WASI Platforms) - -#if !os(WASI) - import OpenAPIURLSession - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - extension CloudKitService { - /// Initialize CloudKit service with web authentication using default URLSessionTransport - /// - /// This convenience initializer is only available on platforms that support URLSession. - /// For WASI builds, use the generic initializer that accepts a transport parameter. - public init( - containerIdentifier: String, - apiToken: String, - webAuthToken: String - ) throws { - try self.init( - containerIdentifier: containerIdentifier, - apiToken: apiToken, - webAuthToken: webAuthToken, - transport: URLSessionTransport() - ) - } - - /// Initialize CloudKit service with API-only authentication using default URLSessionTransport - /// - /// This convenience initializer is only available on platforms that support URLSession. - /// For WASI builds, use the generic initializer that accepts a transport parameter. - public init( - containerIdentifier: String, - apiToken: String - ) throws { - try self.init( - containerIdentifier: containerIdentifier, - apiToken: apiToken, - transport: URLSessionTransport() - ) - } - - /// Initialize CloudKit service with a custom TokenManager using default URLSessionTransport - /// - /// This convenience initializer is only available on platforms that support URLSession. - /// For WASI builds, use the generic initializer that accepts a transport parameter. - public init( - containerIdentifier: String, - tokenManager: any TokenManager, - environment: Environment = .development, - database: Database = .private - ) throws { - try self.init( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: environment, - database: database, - transport: URLSessionTransport() - ) - } - } -#endif diff --git a/Sources/MistKit/Service/CloudKitService+Operations.swift b/Sources/MistKit/Service/CloudKitService+Operations.swift deleted file mode 100644 index 62d3f3d0..00000000 --- a/Sources/MistKit/Service/CloudKitService+Operations.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// CloudKitService+Operations.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if !os(WASI) - import OpenAPIURLSession -#endif - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - /// Query records from the default zone - /// - /// Queries CloudKit records with optional filtering and sorting. - /// Supports all CloudKit filter operations (equals, comparisons, - /// string matching, list operations) and field-based sorting. - /// - /// - Parameters: - /// - recordType: The type of records to query (must not be empty) - /// - filters: Optional array of filters to apply to the query - /// - sortBy: Optional array of sort descriptors - /// - limit: Maximum number of records to return - /// (1-200, defaults to `defaultQueryLimit`) - /// - desiredKeys: Optional array of field names to fetch - /// - Returns: Array of matching records - /// - Throws: CloudKitError if validation fails or the request fails - /// - /// # Example: Basic Query - /// ```swift - /// let articles = try await service.queryRecords( - /// recordType: "Article" - /// ) - /// ``` - /// - /// # Example: Query with Filters - /// ```swift - /// let recentArticles = try await service.queryRecords( - /// recordType: "Article", - /// filters: [ - /// .greaterThan("publishedDate", .date(oneWeekAgo)), - /// .equals("status", .string("published")) - /// ], - /// limit: 50 - /// ) - /// ``` - /// - /// # Example: Query with Sorting - /// ```swift - /// let sortedArticles = try await service.queryRecords( - /// recordType: "Article", - /// sortBy: [.descending("publishedDate")], - /// limit: 20 - /// ) - /// ``` - /// - /// - Note: For large result sets, consider using pagination - public func queryRecords( - recordType: String, - filters: [QueryFilter]? = nil, - sortBy: [QuerySort]? = nil, - limit: Int? = nil, - desiredKeys: [String]? = nil - ) async throws(CloudKitError) -> [RecordInfo] { - let effectiveLimit = limit ?? defaultQueryLimit - - guard !recordType.isEmpty else { - throw CloudKitError.httpErrorWithRawResponse( - statusCode: 400, - rawResponse: "recordType cannot be empty" - ) - } - - guard effectiveLimit > 0 && effectiveLimit <= 200 else { - throw CloudKitError.httpErrorWithRawResponse( - statusCode: 400, - rawResponse: - "limit must be between 1 and 200, got \(effectiveLimit)" - ) - } - - let componentsFilters = filters?.map { - Components.Schemas.Filter(from: $0) - } - let componentsSorts = sortBy?.map { - Components.Schemas.Sort(from: $0) - } - - do { - let response = try await client.queryRecords( - .init( - path: createQueryRecordsPath( - containerIdentifier: containerIdentifier - ), - body: .json( - .init( - zoneID: .init(zoneName: "_defaultZone"), - resultsLimit: effectiveLimit, - query: .init( - recordType: recordType, - filterBy: componentsFilters, - sortBy: componentsSorts - ), - desiredKeys: desiredKeys - ) - ) - ) - ) - - let recordsData: Components.Schemas.QueryResponse = - try await responseProcessor.processQueryRecordsResponse(response) - return recordsData.records?.compactMap { RecordInfo(from: $0) } ?? [] - } catch { - throw mapToCloudKitError(error, context: "queryRecords") - } - } - - /// Modify (create, update, delete) records - @available( - *, deprecated, - message: "Use modifyRecords(_:) with RecordOperation in CloudKitService+WriteOperations instead" - ) - internal func modifyRecords( - operations: [Components.Schemas.RecordOperation], - atomic: Bool = true - ) async throws(CloudKitError) -> [RecordInfo] { - do { - let response = try await client.modifyRecords( - .init( - path: createModifyRecordsPath( - containerIdentifier: containerIdentifier - ), - body: .json( - .init( - operations: operations, - atomic: atomic - ) - ) - ) - ) - - let modifyData: Components.Schemas.ModifyResponse = - try await responseProcessor.processModifyRecordsResponse(response) - return modifyData.records?.compactMap { RecordInfo(from: $0) } ?? [] - } catch { - throw mapToCloudKitError(error, context: "modifyRecords") - } - } - - /// Lookup records by record names - public func lookupRecords( - recordNames: [String], - desiredKeys: [String]? = nil - ) async throws(CloudKitError) -> [RecordInfo] { - do { - let response = try await client.lookupRecords( - .init( - path: createLookupRecordsPath( - containerIdentifier: containerIdentifier - ), - body: .json( - .init( - records: recordNames.map { recordName in - .init( - recordName: recordName, - desiredKeys: desiredKeys - ) - } - ) - ) - ) - ) - - let lookupData: Components.Schemas.LookupResponse = - try await responseProcessor.processLookupRecordsResponse(response) - return lookupData.records?.compactMap { RecordInfo(from: $0) } ?? [] - } catch { - throw mapToCloudKitError(error, context: "lookupRecords") - } - } -} diff --git a/Sources/MistKit/Service/CloudKitService+RecordManaging.swift b/Sources/MistKit/Service/CloudKitService+RecordManaging.swift deleted file mode 100644 index 9de9de34..00000000 --- a/Sources/MistKit/Service/CloudKitService+RecordManaging.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// CloudKitService+RecordManaging.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -/// CloudKitService conformance to RecordManaging protocol -/// -/// This extension makes CloudKitService compatible with the generic RecordManaging -/// operations, enabling protocol-oriented patterns for CloudKit operations. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService: RecordManaging { - /// Query records of a specific type from CloudKit - /// - /// This implementation uses a default limit of 200 records. For more control over - /// query parameters (filters, sorting, custom limits), use the full `queryRecords` - /// method directly on CloudKitService. - /// - /// - Parameter recordType: The CloudKit record type to query - /// - Returns: Array of record information for matching records (up to 200) - /// - Throws: CloudKit errors if the query fails - public func queryRecords(recordType: String) async throws -> [RecordInfo] { - try await self.queryRecords( - recordType: recordType, - filters: nil, - sortBy: nil, - limit: 200 - ) - } - - /// Execute a batch of record operations - /// - /// This implementation delegates to CloudKitService's `modifyRecords` method. - /// The recordType parameter is provided for logging purposes but is not required - /// by the underlying implementation (operation types are embedded in RecordOperation). - /// - /// Note: The caller is responsible for respecting CloudKit's 200 operations/request - /// limit by batching operations. The RecordManaging generic extensions handle this - /// automatically. - /// - /// - Parameters: - /// - operations: Array of record operations to execute - /// - recordType: The record type being operated on (for reference/logging) - /// - Throws: CloudKit errors if the batch operations fail - public func executeBatchOperations( - _ operations: [RecordOperation], - recordType: String - ) async throws { - _ = try await self.modifyRecords(operations) - } -} diff --git a/Sources/MistKit/Service/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/CloudKitService+UserOperations.swift deleted file mode 100644 index 3d3a4bc4..00000000 --- a/Sources/MistKit/Service/CloudKitService+UserOperations.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// CloudKitService+UserOperations.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if !os(WASI) - import OpenAPIURLSession -#endif - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - /// Fetch current user information - public func fetchCurrentUser() async throws(CloudKitError) -> UserInfo { - do { - let response = try await client.getCurrentUser( - .init( - path: createGetCurrentUserPath(containerIdentifier: containerIdentifier) - ) - ) - - let userData: Components.Schemas.UserResponse = - try await responseProcessor.processGetCurrentUserResponse(response) - return UserInfo(from: userData) - } catch { - throw mapToCloudKitError(error, context: "fetchCurrentUser") - } - } - - /// Discover user identities by email addresses or record names - public func discoverUserIdentities( - lookupInfos: [UserIdentityLookupInfo] - ) async throws(CloudKitError) -> [UserIdentity] { - do { - let response = try await client.discoverUserIdentities( - .init( - path: createDiscoverUserIdentitiesPath( - containerIdentifier: containerIdentifier - ), - body: .json( - .init( - lookupInfos: lookupInfos.map { - .init( - emailAddress: $0.emailAddress, - phoneNumber: $0.phoneNumber, - userRecordName: $0.userRecordName - ) - } - ) - ) - ) - ) - - let discoverData: Components.Schemas.DiscoverResponse = - try await responseProcessor.processDiscoverUserIdentitiesResponse( - response - ) - return discoverData.users?.map(UserIdentity.init(from:)) ?? [] - } catch { - throw mapToCloudKitError(error, context: "discoverUserIdentities") - } - } -} diff --git a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift deleted file mode 100644 index e870bfb8..00000000 --- a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// CloudKitService+ZoneOperations.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if !os(WASI) - import OpenAPIURLSession -#endif - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - /// List zones in the user's private database - public func listZones() async throws(CloudKitError) -> [ZoneInfo] { - do { - let response = try await client.listZones( - .init( - path: createListZonesPath(containerIdentifier: containerIdentifier) - ) - ) - - let zonesData: Components.Schemas.ZonesListResponse = - try await responseProcessor.processListZonesResponse(response) - return zonesData.zones?.compactMap { zone in - guard let zoneID = zone.zoneID else { - return nil - } - return ZoneInfo( - zoneName: zoneID.zoneName ?? "Unknown", - ownerRecordName: zoneID.ownerName, - capabilities: [] - ) - } ?? [] - } catch { - throw mapToCloudKitError(error, context: "listZones") - } - } - - /// Lookup specific zones by their IDs - /// - /// Fetches detailed information about multiple zones in a single request. - /// Unlike listZones which returns all zones, this operation retrieves - /// specific zones identified by their zone IDs. - /// - /// - Parameter zoneIDs: Array of zone identifiers to lookup - /// - Returns: Array of ZoneInfo objects for the requested zones - /// - Throws: CloudKitError if the lookup fails - /// - /// Example: - /// ```swift - /// let zones = try await service.lookupZones( - /// zoneIDs: [ - /// ZoneID(zoneName: "Articles", ownerName: nil), - /// ZoneID(zoneName: "Images", ownerName: nil) - /// ] - /// ) - /// ``` - public func lookupZones( - zoneIDs: [ZoneID] - ) async throws(CloudKitError) -> [ZoneInfo] { - guard !zoneIDs.isEmpty else { - throw CloudKitError.httpErrorWithRawResponse( - statusCode: 400, - rawResponse: "zoneIDs cannot be empty" - ) - } - guard zoneIDs.allSatisfy({ !$0.zoneName.isEmpty }) else { - throw CloudKitError.httpErrorWithRawResponse( - statusCode: 400, - rawResponse: "zoneIDs contains a zone with an empty zoneName" - ) - } - - do { - let response = try await client.lookupZones( - .init( - path: createLookupZonesPath( - containerIdentifier: containerIdentifier - ), - body: .json( - .init( - zones: zoneIDs.map { Components.Schemas.ZoneID(from: $0) } - ) - ) - ) - ) - - let zonesData: Components.Schemas.ZonesLookupResponse = - try await responseProcessor.processLookupZonesResponse(response) - - return zonesData.zones?.compactMap { zone in - guard let zoneID = zone.zoneID else { - return nil - } - return ZoneInfo( - zoneName: zoneID.zoneName ?? "Unknown", - ownerRecordName: zoneID.ownerName, - capabilities: [] - ) - } ?? [] - } catch { - throw mapToCloudKitError(error, context: "lookupZones") - } - } - - /// Fetch zone changes since a sync token - /// - /// Retrieves all zones that have changed since the provided sync token. - /// Use this for efficient incremental sync at the zone level. - /// - /// - Parameter syncToken: Optional token from previous fetch - /// (nil = initial fetch) - /// - Returns: ZoneChangesResult containing changed zones and new sync token - /// - Throws: CloudKitError if the fetch fails - /// - /// Example - Initial Sync: - /// ```swift - /// let result = try await service.fetchZoneChanges() - /// // Store result.syncToken for next fetch - /// processZones(result.zones) - /// ``` - /// - /// Example - Incremental Sync: - /// ```swift - /// let result = try await service.fetchZoneChanges( - /// syncToken: previousToken - /// ) - /// ``` - public func fetchZoneChanges( - syncToken: String? = nil - ) async throws(CloudKitError) -> ZoneChangesResult { - do { - let response = try await client.fetchZoneChanges( - .init( - path: createFetchZoneChangesPath( - containerIdentifier: containerIdentifier - ), - body: .json( - .init( - syncToken: syncToken - ) - ) - ) - ) - - let changesData: Components.Schemas.ZoneChangesResponse = - try await responseProcessor.processFetchZoneChangesResponse(response) - - return ZoneChangesResult(from: changesData) - } catch { - throw mapToCloudKitError(error, context: "fetchZoneChanges") - } - } -} diff --git a/Sources/MistKit/Service/CloudKitService.swift b/Sources/MistKit/Service/CloudKitService.swift deleted file mode 100644 index 9ba4ccf5..00000000 --- a/Sources/MistKit/Service/CloudKitService.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// CloudKitService.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if !os(WASI) - import OpenAPIURLSession -#endif - -/// Service for interacting with CloudKit Web Services -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct CloudKitService: Sendable { - /// The CloudKit container identifier - public let containerIdentifier: String - /// The API token for authentication - public let apiToken: String - /// The CloudKit environment (development or production) - public let environment: Environment - /// The CloudKit database (public, private, or shared) - public let database: Database - - /// Default limit for query operations (1-200, default: 100) - internal let defaultQueryLimit: Int = 100 - - internal let mistKitClient: MistKitClient - internal let responseProcessor = CloudKitResponseProcessor() - internal var client: Client { - mistKitClient.client - } -} - -// MARK: - Private Helper Methods - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - /// Create a standard path for getCurrentUser requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createGetCurrentUserPath(containerIdentifier: String) - -> Operations.getCurrentUser.Input.Path - { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for listZones requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createListZonesPath(containerIdentifier: String) - -> Operations.listZones.Input.Path - { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for queryRecords requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createQueryRecordsPath( - containerIdentifier: String - ) -> Operations.queryRecords.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for modifyRecords requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createModifyRecordsPath( - containerIdentifier: String - ) -> Operations.modifyRecords.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for lookupRecords requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createLookupRecordsPath( - containerIdentifier: String - ) -> Operations.lookupRecords.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for lookupZones requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createLookupZonesPath( - containerIdentifier: String - ) -> Operations.lookupZones.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for fetchRecordChanges requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createFetchRecordChangesPath( - containerIdentifier: String - ) -> Operations.fetchRecordChanges.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for uploadAssets requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createUploadAssetsPath( - containerIdentifier: String - ) -> Operations.uploadAssets.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for discoverUserIdentities requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createDiscoverUserIdentitiesPath( - containerIdentifier: String - ) -> Operations.discoverUserIdentities.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } - - /// Create a standard path for fetchZoneChanges requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createFetchZoneChangesPath( - containerIdentifier: String - ) -> Operations.fetchZoneChanges.Input.Path { - .init( - version: "1", - container: containerIdentifier, - environment: .init(from: environment), - database: .init(from: database) - ) - } -} diff --git a/Sources/MistKit/Service/CustomFieldValue.CustomFieldValuePayload+FieldValue.swift b/Sources/MistKit/Service/CustomFieldValue.CustomFieldValuePayload+FieldValue.swift deleted file mode 100644 index 36e61d21..00000000 --- a/Sources/MistKit/Service/CustomFieldValue.CustomFieldValuePayload+FieldValue.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// CustomFieldValue.CustomFieldValuePayload+FieldValue.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Extension to convert MistKit FieldValue to CustomFieldValue.CustomFieldValuePayload -extension CustomFieldValue.CustomFieldValuePayload { - /// Initialize from MistKit FieldValue (for list nesting) - internal init(_ fieldValue: FieldValue) { - if let scalar = Self.makeScalarPayload(from: fieldValue) { - self = scalar - } else { - self = Self.makeComplexPayload(from: fieldValue) - } - } - - /// Initialize from Location to payload value - private init(location: FieldValue.Location) { - self = .locationValue( - Components.Schemas.LocationValue( - latitude: location.latitude, - longitude: location.longitude, - horizontalAccuracy: location.horizontalAccuracy, - verticalAccuracy: location.verticalAccuracy, - altitude: location.altitude, - speed: location.speed, - course: location.course, - timestamp: location.timestamp.map { $0.timeIntervalSince1970 * 1_000 } - ) - ) - } - - /// Initialize from Reference to payload value - private init(reference: FieldValue.Reference) { - let action: Components.Schemas.ReferenceValue.actionPayload? - switch reference.action { - case .some(.deleteSelf): - action = .DELETE_SELF - case .some(.none): - action = .NONE - case nil: - action = nil - } - self = .referenceValue( - Components.Schemas.ReferenceValue( - recordName: reference.recordName, - action: action - ) - ) - } - - /// Initialize from Asset to payload value - private init(asset: FieldValue.Asset) { - self = .assetValue( - Components.Schemas.AssetValue( - fileChecksum: asset.fileChecksum, - size: asset.size, - referenceChecksum: asset.referenceChecksum, - wrappingKey: asset.wrappingKey, - receipt: asset.receipt, - downloadURL: asset.downloadURL - ) - ) - } - - /// Initialize from basic FieldValue types to payload (for nested lists) - private init(basicFieldValue: FieldValue) { - switch basicFieldValue { - case .string(let stringValue): - self = .stringValue(stringValue) - case .int64(let intValue): - self = .int64Value(intValue) - case .double(let doubleValue): - self = .doubleValue(doubleValue) - case .bytes(let bytesValue): - self = .bytesValue(bytesValue) - case .date(let dateValue): - self = .dateValue(dateValue.timeIntervalSince1970 * 1_000) - default: - assertionFailure("Unexpected FieldValue case in basicFieldValue init: \(basicFieldValue)") - self = .stringValue("unsupported") - } - } - - private static func makeScalarPayload(from fieldValue: FieldValue) -> Self? { - if case .string(let value) = fieldValue { - return .stringValue(value) - } - if case .int64(let value) = fieldValue { - return .int64Value(value) - } - if case .double(let value) = fieldValue { - return .doubleValue(value) - } - if case .bytes(let value) = fieldValue { - return .bytesValue(value) - } - if case .date(let value) = fieldValue { - return .dateValue(value.timeIntervalSince1970 * 1_000) - } - return nil - } - - private static func makeComplexPayload(from fieldValue: FieldValue) -> Self { - switch fieldValue { - case .location(let location): - return Self(location: location) - case .reference(let reference): - return Self(reference: reference) - case .asset(let asset): - return Self(asset: asset) - case .list(let nestedList): - return .listValue(nestedList.map { Self(basicFieldValue: $0) }) - default: - assertionFailure("Unexpected FieldValue case in makeComplexPayload: \(fieldValue)") - return .stringValue("") - } - } -} diff --git a/Sources/MistKit/Service/Operations.discoverUserIdentities.Output.swift b/Sources/MistKit/Service/Operations.discoverUserIdentities.Output.swift deleted file mode 100644 index 891db739..00000000 --- a/Sources/MistKit/Service/Operations.discoverUserIdentities.Output.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// Operations.discoverUserIdentities.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.discoverUserIdentities.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { nil } - internal var notFoundResponse: Components.Responses.NotFound? { nil } - internal var conflictResponse: Components.Responses.Conflict? { nil } - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { nil } - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { nil } - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { nil } - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { nil } - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { nil } - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { nil } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Sources/MistKit/Service/Operations.fetchRecordChanges.Output.swift b/Sources/MistKit/Service/Operations.fetchRecordChanges.Output.swift deleted file mode 100644 index 8bd7325a..00000000 --- a/Sources/MistKit/Service/Operations.fetchRecordChanges.Output.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// Operations.fetchRecordChanges.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.fetchRecordChanges.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { - if case .forbidden(let response) = self { - return response - } else { - return nil - } - } - - internal var notFoundResponse: Components.Responses.NotFound? { - if case .notFound(let response) = self { - return response - } else { - return nil - } - } - - internal var conflictResponse: Components.Responses.Conflict? { - if case .conflict(let response) = self { - return response - } else { - return nil - } - } - - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { - if case .preconditionFailed(let response) = self { - return response - } else { - return nil - } - } - - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { - if case .contentTooLarge(let response) = self { - return response - } else { - return nil - } - } - - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { - if case .misdirectedRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { - if case .tooManyRequests(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } - - // fetchRecordChanges has most error responses except 500/503 - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { nil } - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { nil } -} diff --git a/Sources/MistKit/Service/Operations.fetchZoneChanges.Output.swift b/Sources/MistKit/Service/Operations.fetchZoneChanges.Output.swift deleted file mode 100644 index dcb16f38..00000000 --- a/Sources/MistKit/Service/Operations.fetchZoneChanges.Output.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Operations.fetchZoneChanges.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.fetchZoneChanges.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } - - // fetchZoneChanges only defines 200, 400, 401 responses - internal var forbiddenResponse: Components.Responses.Forbidden? { nil } - internal var notFoundResponse: Components.Responses.NotFound? { nil } - internal var conflictResponse: Components.Responses.Conflict? { nil } - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { nil } - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { nil } - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { nil } - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { nil } - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { nil } - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { nil } -} diff --git a/Sources/MistKit/Service/Operations.getCurrentUser.Output.swift b/Sources/MistKit/Service/Operations.getCurrentUser.Output.swift deleted file mode 100644 index 6b550ca9..00000000 --- a/Sources/MistKit/Service/Operations.getCurrentUser.Output.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Operations.getCurrentUser.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.getCurrentUser.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { - if case .forbidden(let response) = self { - return response - } else { - return nil - } - } - - internal var notFoundResponse: Components.Responses.NotFound? { - if case .notFound(let response) = self { - return response - } else { - return nil - } - } - - internal var conflictResponse: Components.Responses.Conflict? { - if case .conflict(let response) = self { - return response - } else { - return nil - } - } - - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { - if case .preconditionFailed(let response) = self { - return response - } else { - return nil - } - } - - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { - if case .contentTooLarge(let response) = self { - return response - } else { - return nil - } - } - - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { - if case .misdirectedRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { - if case .tooManyRequests(let response) = self { - return response - } else { - return nil - } - } - - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { - if case .internalServerError(let response) = self { - return response - } else { - return nil - } - } - - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { - if case .serviceUnavailable(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Sources/MistKit/Service/Operations.listZones.Output.swift b/Sources/MistKit/Service/Operations.listZones.Output.swift deleted file mode 100644 index 58854917..00000000 --- a/Sources/MistKit/Service/Operations.listZones.Output.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Operations.listZones.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.listZones.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { - if case .forbidden(let response) = self { - return response - } else { - return nil - } - } - - internal var notFoundResponse: Components.Responses.NotFound? { - if case .notFound(let response) = self { - return response - } else { - return nil - } - } - - internal var conflictResponse: Components.Responses.Conflict? { - if case .conflict(let response) = self { - return response - } else { - return nil - } - } - - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { - if case .preconditionFailed(let response) = self { - return response - } else { - return nil - } - } - - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { - if case .contentTooLarge(let response) = self { - return response - } else { - return nil - } - } - - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { - if case .misdirectedRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { - if case .tooManyRequests(let response) = self { - return response - } else { - return nil - } - } - - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { - if case .internalServerError(let response) = self { - return response - } else { - return nil - } - } - - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { - if case .serviceUnavailable(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Sources/MistKit/Service/Operations.lookupRecords.Output.swift b/Sources/MistKit/Service/Operations.lookupRecords.Output.swift deleted file mode 100644 index 018ba896..00000000 --- a/Sources/MistKit/Service/Operations.lookupRecords.Output.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Operations.lookupRecords.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.lookupRecords.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { - if case .forbidden(let response) = self { - return response - } else { - return nil - } - } - - internal var notFoundResponse: Components.Responses.NotFound? { - if case .notFound(let response) = self { - return response - } else { - return nil - } - } - - internal var conflictResponse: Components.Responses.Conflict? { - if case .conflict(let response) = self { - return response - } else { - return nil - } - } - - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { - if case .preconditionFailed(let response) = self { - return response - } else { - return nil - } - } - - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { - if case .contentTooLarge(let response) = self { - return response - } else { - return nil - } - } - - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { - if case .misdirectedRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { - if case .tooManyRequests(let response) = self { - return response - } else { - return nil - } - } - - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { - if case .internalServerError(let response) = self { - return response - } else { - return nil - } - } - - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { - if case .serviceUnavailable(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Sources/MistKit/Service/Operations.lookupZones.Output.swift b/Sources/MistKit/Service/Operations.lookupZones.Output.swift deleted file mode 100644 index 6ffe821b..00000000 --- a/Sources/MistKit/Service/Operations.lookupZones.Output.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Operations.lookupZones.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.lookupZones.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } - - // lookupZones only has 400/401 errors per OpenAPI spec - internal var forbiddenResponse: Components.Responses.Forbidden? { nil } - internal var notFoundResponse: Components.Responses.NotFound? { nil } - internal var conflictResponse: Components.Responses.Conflict? { nil } - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { nil } - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { nil } - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { nil } - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { nil } - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { nil } - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { nil } -} diff --git a/Sources/MistKit/Service/Operations.modifyRecords.Output.swift b/Sources/MistKit/Service/Operations.modifyRecords.Output.swift deleted file mode 100644 index 3feff529..00000000 --- a/Sources/MistKit/Service/Operations.modifyRecords.Output.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Operations.modifyRecords.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.modifyRecords.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { - if case .forbidden(let response) = self { - return response - } else { - return nil - } - } - - internal var notFoundResponse: Components.Responses.NotFound? { - if case .notFound(let response) = self { - return response - } else { - return nil - } - } - - internal var conflictResponse: Components.Responses.Conflict? { - if case .conflict(let response) = self { - return response - } else { - return nil - } - } - - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { - if case .preconditionFailed(let response) = self { - return response - } else { - return nil - } - } - - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { - if case .contentTooLarge(let response) = self { - return response - } else { - return nil - } - } - - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { - if case .misdirectedRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { - if case .tooManyRequests(let response) = self { - return response - } else { - return nil - } - } - - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { - if case .internalServerError(let response) = self { - return response - } else { - return nil - } - } - - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { - if case .serviceUnavailable(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Sources/MistKit/Service/Operations.queryRecords.Output.swift b/Sources/MistKit/Service/Operations.queryRecords.Output.swift deleted file mode 100644 index 578f5aba..00000000 --- a/Sources/MistKit/Service/Operations.queryRecords.Output.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Operations.queryRecords.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.queryRecords.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var forbiddenResponse: Components.Responses.Forbidden? { - if case .forbidden(let response) = self { - return response - } else { - return nil - } - } - - internal var notFoundResponse: Components.Responses.NotFound? { - if case .notFound(let response) = self { - return response - } else { - return nil - } - } - - internal var conflictResponse: Components.Responses.Conflict? { - if case .conflict(let response) = self { - return response - } else { - return nil - } - } - - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { - if case .preconditionFailed(let response) = self { - return response - } else { - return nil - } - } - - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { - if case .contentTooLarge(let response) = self { - return response - } else { - return nil - } - } - - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { - if case .misdirectedRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { - if case .tooManyRequests(let response) = self { - return response - } else { - return nil - } - } - - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { - if case .internalServerError(let response) = self { - return response - } else { - return nil - } - } - - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { - if case .serviceUnavailable(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Sources/MistKit/Service/Operations.uploadAssets.Output.swift b/Sources/MistKit/Service/Operations.uploadAssets.Output.swift deleted file mode 100644 index a2ecdcb5..00000000 --- a/Sources/MistKit/Service/Operations.uploadAssets.Output.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Operations.uploadAssets.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.uploadAssets.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } - - // uploadAssets only has 400/401 errors per OpenAPI spec - internal var forbiddenResponse: Components.Responses.Forbidden? { nil } - internal var notFoundResponse: Components.Responses.NotFound? { nil } - internal var conflictResponse: Components.Responses.Conflict? { nil } - internal var preconditionFailedResponse: Components.Responses.PreconditionFailed? { nil } - internal var contentTooLargeResponse: Components.Responses.RequestEntityTooLarge? { nil } - internal var misdirectedRequestResponse: Components.Responses.UnprocessableEntity? { nil } - internal var tooManyRequestsResponse: Components.Responses.TooManyRequests? { nil } - internal var internalServerErrorResponse: Components.Responses.InternalServerError? { nil } - internal var serviceUnavailableResponse: Components.Responses.ServiceUnavailable? { nil } -} diff --git a/Sources/MistKit/URL.swift b/Sources/MistKit/URL.swift deleted file mode 100644 index 20b680c9..00000000 --- a/Sources/MistKit/URL.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// URL.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -extension URL { - /// MistKit URL constants and utilities - public enum MistKit { - // swiftlint:disable force_try - // swift-format-ignore: NeverUseForceTry - /// The base URL for CloudKit Web Services API - public static let cloudKitAPI: URL = try! Servers.Server1.url() - // swiftlint:enable force_try - } -} diff --git a/Sources/MistKit/Utilities/Array+Chunked.swift b/Sources/MistKit/Utilities/Array+Chunked.swift deleted file mode 100644 index 471303b7..00000000 --- a/Sources/MistKit/Utilities/Array+Chunked.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Array+Chunked.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation - -extension Array { - /// Split array into chunks of specified size - /// - /// This utility is used to batch CloudKit operations which have a limit of 200 operations per request. - /// - /// - Parameter size: The maximum size of each chunk - /// - Returns: Array of arrays, each containing at most `size` elements - public func chunked(into size: Int) -> [[Element]] { - stride(from: 0, to: count, by: size).map { - Array(self[$0.. [NSTextCheckingResult] { - let range = NSRange(string.startIndex.. Operations.queryRecords.Output { + public func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output { try await client.send( input: input, forOperation: Operations.queryRecords.id, @@ -122,7 +122,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -144,7 +144,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -166,7 +166,7 @@ internal struct Client: APIProtocol { return .unauthorized(.init(body: body)) case 403: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Forbidden.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -188,7 +188,7 @@ internal struct Client: APIProtocol { return .forbidden(.init(body: body)) case 404: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.NotFound.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -210,7 +210,7 @@ internal struct Client: APIProtocol { return .notFound(.init(body: body)) case 409: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Conflict.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -232,7 +232,7 @@ internal struct Client: APIProtocol { return .conflict(.init(body: body)) case 412: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.PreconditionFailed.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -254,7 +254,7 @@ internal struct Client: APIProtocol { return .preconditionFailed(.init(body: body)) case 413: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.RequestEntityTooLarge.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -276,7 +276,7 @@ internal struct Client: APIProtocol { return .contentTooLarge(.init(body: body)) case 429: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.TooManyRequests.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -298,7 +298,7 @@ internal struct Client: APIProtocol { return .tooManyRequests(.init(body: body)) case 421: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.UnprocessableEntity.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -320,7 +320,7 @@ internal struct Client: APIProtocol { return .misdirectedRequest(.init(body: body)) case 500: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.InternalServerError.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -342,7 +342,7 @@ internal struct Client: APIProtocol { return .internalServerError(.init(body: body)) case 503: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.ServiceUnavailable.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -380,7 +380,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. - internal func modifyRecords(_ input: Operations.modifyRecords.Input) async throws -> Operations.modifyRecords.Output { + public func modifyRecords(_ input: Operations.modifyRecords.Input) async throws -> Operations.modifyRecords.Output { try await client.send( input: input, forOperation: Operations.modifyRecords.id, @@ -440,7 +440,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -462,7 +462,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -484,7 +484,7 @@ internal struct Client: APIProtocol { return .unauthorized(.init(body: body)) case 403: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Forbidden.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -506,7 +506,7 @@ internal struct Client: APIProtocol { return .forbidden(.init(body: body)) case 404: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.NotFound.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -528,7 +528,7 @@ internal struct Client: APIProtocol { return .notFound(.init(body: body)) case 409: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Conflict.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -550,7 +550,7 @@ internal struct Client: APIProtocol { return .conflict(.init(body: body)) case 412: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.PreconditionFailed.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -572,7 +572,7 @@ internal struct Client: APIProtocol { return .preconditionFailed(.init(body: body)) case 413: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.RequestEntityTooLarge.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -594,7 +594,7 @@ internal struct Client: APIProtocol { return .contentTooLarge(.init(body: body)) case 429: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.TooManyRequests.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -616,7 +616,7 @@ internal struct Client: APIProtocol { return .tooManyRequests(.init(body: body)) case 421: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.UnprocessableEntity.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -638,7 +638,7 @@ internal struct Client: APIProtocol { return .misdirectedRequest(.init(body: body)) case 500: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.InternalServerError.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -660,7 +660,7 @@ internal struct Client: APIProtocol { return .internalServerError(.init(body: body)) case 503: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.ServiceUnavailable.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -698,7 +698,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. - internal func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output { + public func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output { try await client.send( input: input, forOperation: Operations.lookupRecords.id, @@ -758,7 +758,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -780,7 +780,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -802,7 +802,7 @@ internal struct Client: APIProtocol { return .unauthorized(.init(body: body)) case 403: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Forbidden.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -824,7 +824,7 @@ internal struct Client: APIProtocol { return .forbidden(.init(body: body)) case 404: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.NotFound.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -846,7 +846,7 @@ internal struct Client: APIProtocol { return .notFound(.init(body: body)) case 409: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Conflict.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -868,7 +868,7 @@ internal struct Client: APIProtocol { return .conflict(.init(body: body)) case 412: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.PreconditionFailed.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -890,7 +890,7 @@ internal struct Client: APIProtocol { return .preconditionFailed(.init(body: body)) case 413: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.RequestEntityTooLarge.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -912,7 +912,7 @@ internal struct Client: APIProtocol { return .contentTooLarge(.init(body: body)) case 429: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.TooManyRequests.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -934,7 +934,7 @@ internal struct Client: APIProtocol { return .tooManyRequests(.init(body: body)) case 421: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.UnprocessableEntity.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -956,7 +956,7 @@ internal struct Client: APIProtocol { return .misdirectedRequest(.init(body: body)) case 500: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.InternalServerError.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -978,7 +978,7 @@ internal struct Client: APIProtocol { return .internalServerError(.init(body: body)) case 503: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.ServiceUnavailable.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1016,7 +1016,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. - internal func fetchRecordChanges(_ input: Operations.fetchRecordChanges.Input) async throws -> Operations.fetchRecordChanges.Output { + public func fetchRecordChanges(_ input: Operations.fetchRecordChanges.Input) async throws -> Operations.fetchRecordChanges.Output { try await client.send( input: input, forOperation: Operations.fetchRecordChanges.id, @@ -1076,7 +1076,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1098,7 +1098,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1120,7 +1120,7 @@ internal struct Client: APIProtocol { return .unauthorized(.init(body: body)) case 403: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Forbidden.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1142,7 +1142,7 @@ internal struct Client: APIProtocol { return .forbidden(.init(body: body)) case 404: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.NotFound.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1164,7 +1164,7 @@ internal struct Client: APIProtocol { return .notFound(.init(body: body)) case 409: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Conflict.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1186,7 +1186,7 @@ internal struct Client: APIProtocol { return .conflict(.init(body: body)) case 412: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.PreconditionFailed.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1208,7 +1208,7 @@ internal struct Client: APIProtocol { return .preconditionFailed(.init(body: body)) case 413: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.RequestEntityTooLarge.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1230,7 +1230,7 @@ internal struct Client: APIProtocol { return .contentTooLarge(.init(body: body)) case 429: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.TooManyRequests.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1252,7 +1252,7 @@ internal struct Client: APIProtocol { return .tooManyRequests(.init(body: body)) case 421: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.UnprocessableEntity.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1274,7 +1274,7 @@ internal struct Client: APIProtocol { return .misdirectedRequest(.init(body: body)) case 500: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.InternalServerError.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1296,7 +1296,7 @@ internal struct Client: APIProtocol { return .internalServerError(.init(body: body)) case 503: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.ServiceUnavailable.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1334,7 +1334,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. - internal func listZones(_ input: Operations.listZones.Input) async throws -> Operations.listZones.Output { + public func listZones(_ input: Operations.listZones.Input) async throws -> Operations.listZones.Output { try await client.send( input: input, forOperation: Operations.listZones.id, @@ -1385,7 +1385,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1407,7 +1407,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1429,7 +1429,7 @@ internal struct Client: APIProtocol { return .unauthorized(.init(body: body)) case 403: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Forbidden.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1451,7 +1451,7 @@ internal struct Client: APIProtocol { return .forbidden(.init(body: body)) case 404: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.NotFound.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1473,7 +1473,7 @@ internal struct Client: APIProtocol { return .notFound(.init(body: body)) case 409: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Conflict.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1495,7 +1495,7 @@ internal struct Client: APIProtocol { return .conflict(.init(body: body)) case 412: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.PreconditionFailed.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1517,7 +1517,7 @@ internal struct Client: APIProtocol { return .preconditionFailed(.init(body: body)) case 413: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.RequestEntityTooLarge.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1539,7 +1539,7 @@ internal struct Client: APIProtocol { return .contentTooLarge(.init(body: body)) case 429: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.TooManyRequests.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1561,7 +1561,7 @@ internal struct Client: APIProtocol { return .tooManyRequests(.init(body: body)) case 421: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.UnprocessableEntity.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1583,7 +1583,7 @@ internal struct Client: APIProtocol { return .misdirectedRequest(.init(body: body)) case 500: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.InternalServerError.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1605,7 +1605,7 @@ internal struct Client: APIProtocol { return .internalServerError(.init(body: body)) case 503: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.ServiceUnavailable.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1643,7 +1643,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. - internal func lookupZones(_ input: Operations.lookupZones.Input) async throws -> Operations.lookupZones.Output { + public func lookupZones(_ input: Operations.lookupZones.Input) async throws -> Operations.lookupZones.Output { try await client.send( input: input, forOperation: Operations.lookupZones.id, @@ -1703,7 +1703,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1725,7 +1725,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1763,7 +1763,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. - internal func modifyZones(_ input: Operations.modifyZones.Input) async throws -> Operations.modifyZones.Output { + public func modifyZones(_ input: Operations.modifyZones.Input) async throws -> Operations.modifyZones.Output { try await client.send( input: input, forOperation: Operations.modifyZones.id, @@ -1823,7 +1823,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1845,7 +1845,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1883,7 +1883,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. - internal func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output { + public func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output { try await client.send( input: input, forOperation: Operations.fetchZoneChanges.id, @@ -1943,7 +1943,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -1965,7 +1965,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2003,7 +2003,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. - internal func listSubscriptions(_ input: Operations.listSubscriptions.Input) async throws -> Operations.listSubscriptions.Output { + public func listSubscriptions(_ input: Operations.listSubscriptions.Input) async throws -> Operations.listSubscriptions.Output { try await client.send( input: input, forOperation: Operations.listSubscriptions.id, @@ -2054,7 +2054,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2076,7 +2076,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2114,7 +2114,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. - internal func lookupSubscriptions(_ input: Operations.lookupSubscriptions.Input) async throws -> Operations.lookupSubscriptions.Output { + public func lookupSubscriptions(_ input: Operations.lookupSubscriptions.Input) async throws -> Operations.lookupSubscriptions.Output { try await client.send( input: input, forOperation: Operations.lookupSubscriptions.id, @@ -2174,7 +2174,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2196,7 +2196,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2234,7 +2234,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. - internal func modifySubscriptions(_ input: Operations.modifySubscriptions.Input) async throws -> Operations.modifySubscriptions.Output { + public func modifySubscriptions(_ input: Operations.modifySubscriptions.Input) async throws -> Operations.modifySubscriptions.Output { try await client.send( input: input, forOperation: Operations.modifySubscriptions.id, @@ -2294,7 +2294,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2316,7 +2316,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2348,19 +2348,23 @@ internal struct Client: APIProtocol { } ) } - /// Get Current User + /// Get the Caller (Current User) /// - /// Fetch the current authenticated user's information + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - internal func getCurrentUser(_ input: Operations.getCurrentUser.Input) async throws -> Operations.getCurrentUser.Output { + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + public func getCaller(_ input: Operations.getCaller.Input) async throws -> Operations.getCaller.Output { try await client.send( input: input, - forOperation: Operations.getCurrentUser.id, + forOperation: Operations.getCaller.id, serializer: { input in let path = try converter.renderedPath( - template: "/database/{}/{}/{}/{}/users/current", + template: "/database/{}/{}/{}/{}/users/caller", parameters: [ input.path.version, input.path.container, @@ -2383,7 +2387,7 @@ internal struct Client: APIProtocol { switch response.status.code { case 200: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Operations.getCurrentUser.Output.Ok.Body + let body: Operations.getCaller.Output.Ok.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2405,7 +2409,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2427,7 +2431,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2449,7 +2453,7 @@ internal struct Client: APIProtocol { return .unauthorized(.init(body: body)) case 403: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Forbidden.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2471,7 +2475,7 @@ internal struct Client: APIProtocol { return .forbidden(.init(body: body)) case 404: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.NotFound.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2493,7 +2497,7 @@ internal struct Client: APIProtocol { return .notFound(.init(body: body)) case 409: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Conflict.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2515,7 +2519,7 @@ internal struct Client: APIProtocol { return .conflict(.init(body: body)) case 412: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.PreconditionFailed.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2537,7 +2541,7 @@ internal struct Client: APIProtocol { return .preconditionFailed(.init(body: body)) case 413: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.RequestEntityTooLarge.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2559,7 +2563,7 @@ internal struct Client: APIProtocol { return .contentTooLarge(.init(body: body)) case 429: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.TooManyRequests.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2581,7 +2585,7 @@ internal struct Client: APIProtocol { return .tooManyRequests(.init(body: body)) case 421: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.UnprocessableEntity.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2603,7 +2607,7 @@ internal struct Client: APIProtocol { return .misdirectedRequest(.init(body: body)) case 500: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.InternalServerError.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2625,7 +2629,7 @@ internal struct Client: APIProtocol { return .internalServerError(.init(body: body)) case 503: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.ServiceUnavailable.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2657,13 +2661,128 @@ internal struct Client: APIProtocol { } ) } + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + public func discoverAllUserIdentities(_ input: Operations.discoverAllUserIdentities.Input) async throws -> Operations.discoverAllUserIdentities.Output { + try await client.send( + input: input, + forOperation: Operations.discoverAllUserIdentities.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/users/discover", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.discoverAllUserIdentities.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DiscoverResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// Discover User Identities /// /// Discover all user identities based on email addresses or user record names /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. - internal func discoverUserIdentities(_ input: Operations.discoverUserIdentities.Input) async throws -> Operations.discoverUserIdentities.Output { + public func discoverUserIdentities(_ input: Operations.discoverUserIdentities.Input) async throws -> Operations.discoverUserIdentities.Output { try await client.send( input: input, forOperation: Operations.discoverUserIdentities.id, @@ -2723,7 +2842,252 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + public func lookupUsersByEmail(_ input: Operations.lookupUsersByEmail.Input) async throws -> Operations.lookupUsersByEmail.Output { + try await client.send( + input: input, + forOperation: Operations.lookupUsersByEmail.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/users/lookup/email", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.lookupUsersByEmail.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DiscoverResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + public func lookupUsersByRecordName(_ input: Operations.lookupUsersByRecordName.Input) async throws -> Operations.lookupUsersByRecordName.Output { + try await client.send( + input: input, + forOperation: Operations.lookupUsersByRecordName.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/users/lookup/id", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.lookupUsersByRecordName.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DiscoverResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2745,7 +3109,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2784,7 +3148,7 @@ internal struct Client: APIProtocol { /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. @available(*, deprecated) - internal func lookupContacts(_ input: Operations.lookupContacts.Input) async throws -> Operations.lookupContacts.Output { + public func lookupContacts(_ input: Operations.lookupContacts.Input) async throws -> Operations.lookupContacts.Output { try await client.send( input: input, forOperation: Operations.lookupContacts.id, @@ -2844,7 +3208,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2866,7 +3230,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2909,7 +3273,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. - internal func uploadAssets(_ input: Operations.uploadAssets.Input) async throws -> Operations.uploadAssets.Output { + public func uploadAssets(_ input: Operations.uploadAssets.Input) async throws -> Operations.uploadAssets.Output { try await client.send( input: input, forOperation: Operations.uploadAssets.id, @@ -2969,7 +3333,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2991,7 +3355,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -3029,7 +3393,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. - internal func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output { + public func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output { try await client.send( input: input, forOperation: Operations.createToken.id, @@ -3089,7 +3453,7 @@ internal struct Client: APIProtocol { return .ok(.init(body: body)) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -3111,7 +3475,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -3149,7 +3513,7 @@ internal struct Client: APIProtocol { /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. - internal func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output { + public func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output { try await client.send( input: input, forOperation: Operations.registerToken.id, @@ -3189,7 +3553,7 @@ internal struct Client: APIProtocol { return .ok(.init()) case 400: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.BadRequest.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -3211,7 +3575,7 @@ internal struct Client: APIProtocol { return .badRequest(.init(body: body)) case 401: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Components.Responses.Unauthorized.Body + let body: Components.Responses.Failure.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift new file mode 100644 index 00000000..1235c05c --- /dev/null +++ b/Sources/MistKitOpenAPI/Types.swift @@ -0,0 +1,9728 @@ +// Generated by swift-openapi-generator, do not modify. +// periphery:ignore:all +// swift-format-ignore-file +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.URL +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +#else +import struct Foundation.URL +import struct Foundation.Data +import struct Foundation.Date +#endif +/// A type that performs HTTP operations defined by the OpenAPI document. +public protocol APIProtocol: Sendable { + /// Query Records + /// + /// Fetch records using a query with filters and sorting options + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)`. + func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output + /// Modify Records + /// + /// Create, update, or delete records (supports bulk operations) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. + func modifyRecords(_ input: Operations.modifyRecords.Input) async throws -> Operations.modifyRecords.Output + /// Lookup Records + /// + /// Fetch specific records by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. + func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output + /// Fetch Record Changes + /// + /// Get all record changes relative to a sync token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. + func fetchRecordChanges(_ input: Operations.fetchRecordChanges.Input) async throws -> Operations.fetchRecordChanges.Output + /// List All Zones + /// + /// Fetch all zones in the database + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. + func listZones(_ input: Operations.listZones.Input) async throws -> Operations.listZones.Output + /// Lookup Zones + /// + /// Fetch specific zones by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. + func lookupZones(_ input: Operations.lookupZones.Input) async throws -> Operations.lookupZones.Output + /// Modify Zones + /// + /// Create or delete zones (only supported in private database) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. + func modifyZones(_ input: Operations.modifyZones.Input) async throws -> Operations.modifyZones.Output + /// Fetch Zone Changes + /// + /// Get all changed zones relative to a meta-sync token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. + func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output + /// List All Subscriptions + /// + /// Fetch all subscriptions in the database + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. + func listSubscriptions(_ input: Operations.listSubscriptions.Input) async throws -> Operations.listSubscriptions.Output + /// Lookup Subscriptions + /// + /// Fetch specific subscriptions by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. + func lookupSubscriptions(_ input: Operations.lookupSubscriptions.Input) async throws -> Operations.lookupSubscriptions.Output + /// Modify Subscriptions + /// + /// Create, update, or delete subscriptions + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. + func modifySubscriptions(_ input: Operations.modifySubscriptions.Input) async throws -> Operations.modifySubscriptions.Output + /// Get the Caller (Current User) + /// + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + func getCaller(_ input: Operations.getCaller.Input) async throws -> Operations.getCaller.Output + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + func discoverAllUserIdentities(_ input: Operations.discoverAllUserIdentities.Input) async throws -> Operations.discoverAllUserIdentities.Output + /// Discover User Identities + /// + /// Discover all user identities based on email addresses or user record names + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. + func discoverUserIdentities(_ input: Operations.discoverUserIdentities.Input) async throws -> Operations.discoverUserIdentities.Output + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + func lookupUsersByEmail(_ input: Operations.lookupUsersByEmail.Input) async throws -> Operations.lookupUsersByEmail.Output + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + func lookupUsersByRecordName(_ input: Operations.lookupUsersByRecordName.Input) async throws -> Operations.lookupUsersByRecordName.Output + /// Lookup Contacts (Deprecated) + /// + /// Fetch contacts (This endpoint is deprecated) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. + @available(*, deprecated) + func lookupContacts(_ input: Operations.lookupContacts.Input) async throws -> Operations.lookupContacts.Output + /// Request Asset Upload URLs + /// + /// Request upload URLs for asset fields. This is the first step in a two-step process: + /// 1. Request upload URLs by specifying the record type and field name + /// 2. Upload the actual binary data to the returned URL (separate HTTP request) + /// + /// Upload URLs are valid for 15 minutes. Maximum file size is 15 MB. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. + func uploadAssets(_ input: Operations.uploadAssets.Input) async throws -> Operations.uploadAssets.Output + /// Create APNs Token + /// + /// Create an Apple Push Notification service (APNs) token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output + /// Register Token + /// + /// Register a token for push notifications + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output +} + +/// Convenience overloads for operation inputs. +extension APIProtocol { + /// Query Records + /// + /// Fetch records using a query with filters and sorting options + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)`. + public func queryRecords( + path: Operations.queryRecords.Input.Path, + headers: Operations.queryRecords.Input.Headers = .init(), + body: Operations.queryRecords.Input.Body + ) async throws -> Operations.queryRecords.Output { + try await queryRecords(Operations.queryRecords.Input( + path: path, + headers: headers, + body: body + )) + } + /// Modify Records + /// + /// Create, update, or delete records (supports bulk operations) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. + public func modifyRecords( + path: Operations.modifyRecords.Input.Path, + headers: Operations.modifyRecords.Input.Headers = .init(), + body: Operations.modifyRecords.Input.Body + ) async throws -> Operations.modifyRecords.Output { + try await modifyRecords(Operations.modifyRecords.Input( + path: path, + headers: headers, + body: body + )) + } + /// Lookup Records + /// + /// Fetch specific records by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. + public func lookupRecords( + path: Operations.lookupRecords.Input.Path, + headers: Operations.lookupRecords.Input.Headers = .init(), + body: Operations.lookupRecords.Input.Body + ) async throws -> Operations.lookupRecords.Output { + try await lookupRecords(Operations.lookupRecords.Input( + path: path, + headers: headers, + body: body + )) + } + /// Fetch Record Changes + /// + /// Get all record changes relative to a sync token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. + public func fetchRecordChanges( + path: Operations.fetchRecordChanges.Input.Path, + headers: Operations.fetchRecordChanges.Input.Headers = .init(), + body: Operations.fetchRecordChanges.Input.Body + ) async throws -> Operations.fetchRecordChanges.Output { + try await fetchRecordChanges(Operations.fetchRecordChanges.Input( + path: path, + headers: headers, + body: body + )) + } + /// List All Zones + /// + /// Fetch all zones in the database + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. + public func listZones( + path: Operations.listZones.Input.Path, + headers: Operations.listZones.Input.Headers = .init() + ) async throws -> Operations.listZones.Output { + try await listZones(Operations.listZones.Input( + path: path, + headers: headers + )) + } + /// Lookup Zones + /// + /// Fetch specific zones by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. + public func lookupZones( + path: Operations.lookupZones.Input.Path, + headers: Operations.lookupZones.Input.Headers = .init(), + body: Operations.lookupZones.Input.Body + ) async throws -> Operations.lookupZones.Output { + try await lookupZones(Operations.lookupZones.Input( + path: path, + headers: headers, + body: body + )) + } + /// Modify Zones + /// + /// Create or delete zones (only supported in private database) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. + public func modifyZones( + path: Operations.modifyZones.Input.Path, + headers: Operations.modifyZones.Input.Headers = .init(), + body: Operations.modifyZones.Input.Body + ) async throws -> Operations.modifyZones.Output { + try await modifyZones(Operations.modifyZones.Input( + path: path, + headers: headers, + body: body + )) + } + /// Fetch Zone Changes + /// + /// Get all changed zones relative to a meta-sync token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. + public func fetchZoneChanges( + path: Operations.fetchZoneChanges.Input.Path, + headers: Operations.fetchZoneChanges.Input.Headers = .init(), + body: Operations.fetchZoneChanges.Input.Body + ) async throws -> Operations.fetchZoneChanges.Output { + try await fetchZoneChanges(Operations.fetchZoneChanges.Input( + path: path, + headers: headers, + body: body + )) + } + /// List All Subscriptions + /// + /// Fetch all subscriptions in the database + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. + public func listSubscriptions( + path: Operations.listSubscriptions.Input.Path, + headers: Operations.listSubscriptions.Input.Headers = .init() + ) async throws -> Operations.listSubscriptions.Output { + try await listSubscriptions(Operations.listSubscriptions.Input( + path: path, + headers: headers + )) + } + /// Lookup Subscriptions + /// + /// Fetch specific subscriptions by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. + public func lookupSubscriptions( + path: Operations.lookupSubscriptions.Input.Path, + headers: Operations.lookupSubscriptions.Input.Headers = .init(), + body: Operations.lookupSubscriptions.Input.Body + ) async throws -> Operations.lookupSubscriptions.Output { + try await lookupSubscriptions(Operations.lookupSubscriptions.Input( + path: path, + headers: headers, + body: body + )) + } + /// Modify Subscriptions + /// + /// Create, update, or delete subscriptions + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. + public func modifySubscriptions( + path: Operations.modifySubscriptions.Input.Path, + headers: Operations.modifySubscriptions.Input.Headers = .init(), + body: Operations.modifySubscriptions.Input.Body + ) async throws -> Operations.modifySubscriptions.Output { + try await modifySubscriptions(Operations.modifySubscriptions.Input( + path: path, + headers: headers, + body: body + )) + } + /// Get the Caller (Current User) + /// + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + public func getCaller( + path: Operations.getCaller.Input.Path, + headers: Operations.getCaller.Input.Headers = .init() + ) async throws -> Operations.getCaller.Output { + try await getCaller(Operations.getCaller.Input( + path: path, + headers: headers + )) + } + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + public func discoverAllUserIdentities( + path: Operations.discoverAllUserIdentities.Input.Path, + headers: Operations.discoverAllUserIdentities.Input.Headers = .init() + ) async throws -> Operations.discoverAllUserIdentities.Output { + try await discoverAllUserIdentities(Operations.discoverAllUserIdentities.Input( + path: path, + headers: headers + )) + } + /// Discover User Identities + /// + /// Discover all user identities based on email addresses or user record names + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. + public func discoverUserIdentities( + path: Operations.discoverUserIdentities.Input.Path, + headers: Operations.discoverUserIdentities.Input.Headers = .init(), + body: Operations.discoverUserIdentities.Input.Body + ) async throws -> Operations.discoverUserIdentities.Output { + try await discoverUserIdentities(Operations.discoverUserIdentities.Input( + path: path, + headers: headers, + body: body + )) + } + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + public func lookupUsersByEmail( + path: Operations.lookupUsersByEmail.Input.Path, + headers: Operations.lookupUsersByEmail.Input.Headers = .init(), + body: Operations.lookupUsersByEmail.Input.Body + ) async throws -> Operations.lookupUsersByEmail.Output { + try await lookupUsersByEmail(Operations.lookupUsersByEmail.Input( + path: path, + headers: headers, + body: body + )) + } + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + public func lookupUsersByRecordName( + path: Operations.lookupUsersByRecordName.Input.Path, + headers: Operations.lookupUsersByRecordName.Input.Headers = .init(), + body: Operations.lookupUsersByRecordName.Input.Body + ) async throws -> Operations.lookupUsersByRecordName.Output { + try await lookupUsersByRecordName(Operations.lookupUsersByRecordName.Input( + path: path, + headers: headers, + body: body + )) + } + /// Lookup Contacts (Deprecated) + /// + /// Fetch contacts (This endpoint is deprecated) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. + @available(*, deprecated) + public func lookupContacts( + path: Operations.lookupContacts.Input.Path, + headers: Operations.lookupContacts.Input.Headers = .init(), + body: Operations.lookupContacts.Input.Body + ) async throws -> Operations.lookupContacts.Output { + try await lookupContacts(Operations.lookupContacts.Input( + path: path, + headers: headers, + body: body + )) + } + /// Request Asset Upload URLs + /// + /// Request upload URLs for asset fields. This is the first step in a two-step process: + /// 1. Request upload URLs by specifying the record type and field name + /// 2. Upload the actual binary data to the returned URL (separate HTTP request) + /// + /// Upload URLs are valid for 15 minutes. Maximum file size is 15 MB. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. + public func uploadAssets( + path: Operations.uploadAssets.Input.Path, + headers: Operations.uploadAssets.Input.Headers = .init(), + body: Operations.uploadAssets.Input.Body + ) async throws -> Operations.uploadAssets.Output { + try await uploadAssets(Operations.uploadAssets.Input( + path: path, + headers: headers, + body: body + )) + } + /// Create APNs Token + /// + /// Create an Apple Push Notification service (APNs) token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + public func createToken( + path: Operations.createToken.Input.Path, + headers: Operations.createToken.Input.Headers = .init(), + body: Operations.createToken.Input.Body + ) async throws -> Operations.createToken.Output { + try await createToken(Operations.createToken.Input( + path: path, + headers: headers, + body: body + )) + } + /// Register Token + /// + /// Register a token for push notifications + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + public func registerToken( + path: Operations.registerToken.Input.Path, + headers: Operations.registerToken.Input.Headers = .init(), + body: Operations.registerToken.Input.Body + ) async throws -> Operations.registerToken.Output { + try await registerToken(Operations.registerToken.Input( + path: path, + headers: headers, + body: body + )) + } +} + +/// Server URLs defined in the OpenAPI document. +public enum Servers { + /// CloudKit Web Services API + public enum Server1 { + /// CloudKit Web Services API + public static func url() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", + variables: [] + ) + } + } + /// CloudKit Web Services API + @available(*, deprecated, renamed: "Servers.Server1.url") + public static func server1() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", + variables: [] + ) + } +} + +/// Types generated from the components section of the OpenAPI document. +public enum Components { + /// Types generated from the `#/components/schemas` section of the OpenAPI document. + public enum Schemas { + /// - Remark: Generated from `#/components/schemas/ZoneID`. + public struct ZoneID: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZoneID/zoneName`. + public var zoneName: Swift.String? + /// - Remark: Generated from `#/components/schemas/ZoneID/ownerName`. + public var ownerName: Swift.String? + /// Creates a new `ZoneID`. + /// + /// - Parameters: + /// - zoneName: + /// - ownerName: + public init( + zoneName: Swift.String? = nil, + ownerName: Swift.String? = nil + ) { + self.zoneName = zoneName + self.ownerName = ownerName + } + public enum CodingKeys: String, CodingKey { + case zoneName + case ownerName + } + } + /// - Remark: Generated from `#/components/schemas/Filter`. + public struct Filter: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Filter/comparator`. + @frozen public enum comparatorPayload: String, Codable, Hashable, Sendable, CaseIterable { + case EQUALS = "EQUALS" + case NOT_EQUALS = "NOT_EQUALS" + case LESS_THAN = "LESS_THAN" + case LESS_THAN_OR_EQUALS = "LESS_THAN_OR_EQUALS" + case GREATER_THAN = "GREATER_THAN" + case GREATER_THAN_OR_EQUALS = "GREATER_THAN_OR_EQUALS" + case NEAR = "NEAR" + case CONTAINS_ALL_TOKENS = "CONTAINS_ALL_TOKENS" + case IN = "IN" + case NOT_IN = "NOT_IN" + case CONTAINS_ANY_TOKENS = "CONTAINS_ANY_TOKENS" + case LIST_CONTAINS = "LIST_CONTAINS" + case NOT_LIST_CONTAINS = "NOT_LIST_CONTAINS" + case BEGINS_WITH = "BEGINS_WITH" + case NOT_BEGINS_WITH = "NOT_BEGINS_WITH" + case LIST_MEMBER_BEGINS_WITH = "LIST_MEMBER_BEGINS_WITH" + case NOT_LIST_MEMBER_BEGINS_WITH = "NOT_LIST_MEMBER_BEGINS_WITH" + } + /// - Remark: Generated from `#/components/schemas/Filter/comparator`. + public var comparator: Components.Schemas.Filter.comparatorPayload? + /// - Remark: Generated from `#/components/schemas/Filter/fieldName`. + public var fieldName: Swift.String? + /// - Remark: Generated from `#/components/schemas/Filter/fieldValue`. + public var fieldValue: Components.Schemas.FieldValueRequest? + /// Creates a new `Filter`. + /// + /// - Parameters: + /// - comparator: + /// - fieldName: + /// - fieldValue: + public init( + comparator: Components.Schemas.Filter.comparatorPayload? = nil, + fieldName: Swift.String? = nil, + fieldValue: Components.Schemas.FieldValueRequest? = nil + ) { + self.comparator = comparator + self.fieldName = fieldName + self.fieldValue = fieldValue + } + public enum CodingKeys: String, CodingKey { + case comparator + case fieldName + case fieldValue + } + } + /// - Remark: Generated from `#/components/schemas/Sort`. + public struct Sort: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Sort/fieldName`. + public var fieldName: Swift.String? + /// - Remark: Generated from `#/components/schemas/Sort/ascending`. + public var ascending: Swift.Bool? + /// Creates a new `Sort`. + /// + /// - Parameters: + /// - fieldName: + /// - ascending: + public init( + fieldName: Swift.String? = nil, + ascending: Swift.Bool? = nil + ) { + self.fieldName = fieldName + self.ascending = ascending + } + public enum CodingKeys: String, CodingKey { + case fieldName + case ascending + } + } + /// - Remark: Generated from `#/components/schemas/RecordOperation`. + public struct RecordOperation: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecordOperation/operationType`. + @frozen public enum operationTypePayload: String, Codable, Hashable, Sendable, CaseIterable { + case create = "create" + case update = "update" + case forceUpdate = "forceUpdate" + case replace = "replace" + case forceReplace = "forceReplace" + case delete = "delete" + case forceDelete = "forceDelete" + } + /// - Remark: Generated from `#/components/schemas/RecordOperation/operationType`. + public var operationType: Components.Schemas.RecordOperation.operationTypePayload? + /// - Remark: Generated from `#/components/schemas/RecordOperation/record`. + public var record: Components.Schemas.RecordRequest? + /// Creates a new `RecordOperation`. + /// + /// - Parameters: + /// - operationType: + /// - record: + public init( + operationType: Components.Schemas.RecordOperation.operationTypePayload? = nil, + record: Components.Schemas.RecordRequest? = nil + ) { + self.operationType = operationType + self.record = record + } + public enum CodingKeys: String, CodingKey { + case operationType + case record + } + } + /// Record schema for API requests (fields use FieldValueRequest) + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest`. + public struct RecordRequest: Codable, Hashable, Sendable { + /// The unique identifier for the record + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/recordName`. + public var recordName: Swift.String? + /// The record type (schema name) + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/recordType`. + public var recordType: Swift.String? + /// Change tag for optimistic concurrency control + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/recordChangeTag`. + public var recordChangeTag: Swift.String? + /// Record fields with their values (no type metadata) + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/fields`. + public struct fieldsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: Components.Schemas.FieldValueRequest] + /// Creates a new `fieldsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: Components.Schemas.FieldValueRequest] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// Record fields with their values (no type metadata) + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/fields`. + public var fields: Components.Schemas.RecordRequest.fieldsPayload? + /// Creates a new `RecordRequest`. + /// + /// - Parameters: + /// - recordName: The unique identifier for the record + /// - recordType: The record type (schema name) + /// - recordChangeTag: Change tag for optimistic concurrency control + /// - fields: Record fields with their values (no type metadata) + public init( + recordName: Swift.String? = nil, + recordType: Swift.String? = nil, + recordChangeTag: Swift.String? = nil, + fields: Components.Schemas.RecordRequest.fieldsPayload? = nil + ) { + self.recordName = recordName + self.recordType = recordType + self.recordChangeTag = recordChangeTag + self.fields = fields + } + public enum CodingKeys: String, CodingKey { + case recordName + case recordType + case recordChangeTag + case fields + } + } + /// Record schema for API responses (fields use FieldValueResponse) + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse`. + public struct RecordResponse: Codable, Hashable, Sendable { + /// The unique identifier for the record + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/recordName`. + public var recordName: Swift.String? + /// The record type (schema name) + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/recordType`. + public var recordType: Swift.String? + /// Change tag for optimistic concurrency control + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/recordChangeTag`. + public var recordChangeTag: Swift.String? + /// Record fields with their values and optional type information + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/fields`. + public struct fieldsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: Components.Schemas.FieldValueResponse] + /// Creates a new `fieldsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: Components.Schemas.FieldValueResponse] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// Record fields with their values and optional type information + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/fields`. + public var fields: Components.Schemas.RecordResponse.fieldsPayload? + /// - Remark: Generated from `#/components/schemas/RecordResponse/created`. + public var created: Components.Schemas.RecordTimestamp? + /// - Remark: Generated from `#/components/schemas/RecordResponse/modified`. + public var modified: Components.Schemas.RecordTimestamp? + /// Whether the record was deleted + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/deleted`. + public var deleted: Swift.Bool? + /// Creates a new `RecordResponse`. + /// + /// - Parameters: + /// - recordName: The unique identifier for the record + /// - recordType: The record type (schema name) + /// - recordChangeTag: Change tag for optimistic concurrency control + /// - fields: Record fields with their values and optional type information + /// - created: + /// - modified: + /// - deleted: Whether the record was deleted + public init( + recordName: Swift.String? = nil, + recordType: Swift.String? = nil, + recordChangeTag: Swift.String? = nil, + fields: Components.Schemas.RecordResponse.fieldsPayload? = nil, + created: Components.Schemas.RecordTimestamp? = nil, + modified: Components.Schemas.RecordTimestamp? = nil, + deleted: Swift.Bool? = nil + ) { + self.recordName = recordName + self.recordType = recordType + self.recordChangeTag = recordChangeTag + self.fields = fields + self.created = created + self.modified = modified + self.deleted = deleted + } + public enum CodingKeys: String, CodingKey { + case recordName + case recordType + case recordChangeTag + case fields + case created + case modified + case deleted + } + } + /// A CloudKit field value for API requests. + /// The type field is optional and used for IN/NOT_IN list filters to specify the list element type. + /// + /// + /// - Remark: Generated from `#/components/schemas/FieldValueRequest`. + public struct FieldValueRequest: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value`. + @frozen public enum valuePayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case1`. + case StringValue(Components.Schemas.StringValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case2`. + case Int64Value(Components.Schemas.Int64Value) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case3`. + case DoubleValue(Components.Schemas.DoubleValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case4`. + case BytesValue(Components.Schemas.BytesValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case5`. + case DateValue(Components.Schemas.DateValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case6`. + case LocationValue(Components.Schemas.LocationValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case7`. + case ReferenceValue(Components.Schemas.ReferenceValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case8`. + case AssetValue(Components.Schemas.AssetValue) + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value/case9`. + case ListValue(Components.Schemas.ListValue) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .StringValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .Int64Value(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .DoubleValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .BytesValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .DateValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .LocationValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ReferenceValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .AssetValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ListValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .StringValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .Int64Value(value): + try encoder.encodeToSingleValueContainer(value) + case let .DoubleValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .BytesValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .DateValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .LocationValue(value): + try value.encode(to: encoder) + case let .ReferenceValue(value): + try value.encode(to: encoder) + case let .AssetValue(value): + try value.encode(to: encoder) + case let .ListValue(value): + try encoder.encodeToSingleValueContainer(value) + } + } + } + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value`. + public var value: Components.Schemas.FieldValueRequest.valuePayload + /// Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + /// + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/type`. + @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + case STRING_LIST = "STRING_LIST" + case INT64_LIST = "INT64_LIST" + case DOUBLE_LIST = "DOUBLE_LIST" + case BYTES_LIST = "BYTES_LIST" + case TIMESTAMP_LIST = "TIMESTAMP_LIST" + case REFERENCE_LIST = "REFERENCE_LIST" + case LOCATION_LIST = "LOCATION_LIST" + case ASSET_LIST = "ASSET_LIST" + case LIST = "LIST" + } + /// Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + /// + /// - Remark: Generated from `#/components/schemas/FieldValueRequest/type`. + public var _type: Components.Schemas.FieldValueRequest._typePayload? + /// Creates a new `FieldValueRequest`. + /// + /// - Parameters: + /// - value: + /// - _type: Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + public init( + value: Components.Schemas.FieldValueRequest.valuePayload, + _type: Components.Schemas.FieldValueRequest._typePayload? = nil + ) { + self.value = value + self._type = _type + } + public enum CodingKeys: String, CodingKey { + case value + case _type = "type" + } + } + /// A CloudKit field value from API responses. + /// May include optional type field for explicit type information. + /// + /// + /// - Remark: Generated from `#/components/schemas/FieldValueResponse`. + public struct FieldValueResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value`. + @frozen public enum valuePayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case1`. + case StringValue(Components.Schemas.StringValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case2`. + case Int64Value(Components.Schemas.Int64Value) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case3`. + case DoubleValue(Components.Schemas.DoubleValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case4`. + case BytesValue(Components.Schemas.BytesValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case5`. + case DateValue(Components.Schemas.DateValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case6`. + case LocationValue(Components.Schemas.LocationValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case7`. + case ReferenceValue(Components.Schemas.ReferenceValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case8`. + case AssetValue(Components.Schemas.AssetValue) + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value/case9`. + case ListValue(Components.Schemas.ListValue) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .StringValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .Int64Value(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .DoubleValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .BytesValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .DateValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .LocationValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ReferenceValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .AssetValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ListValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .StringValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .Int64Value(value): + try encoder.encodeToSingleValueContainer(value) + case let .DoubleValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .BytesValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .DateValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .LocationValue(value): + try value.encode(to: encoder) + case let .ReferenceValue(value): + try value.encode(to: encoder) + case let .AssetValue(value): + try value.encode(to: encoder) + case let .ListValue(value): + try encoder.encodeToSingleValueContainer(value) + } + } + } + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value`. + public var value: Components.Schemas.FieldValueResponse.valuePayload + /// The CloudKit field type (optional, may be inferred from value) + /// + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/type`. + @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + case STRING = "STRING" + case INT64 = "INT64" + case DOUBLE = "DOUBLE" + case BYTES = "BYTES" + case REFERENCE = "REFERENCE" + case ASSET = "ASSET" + case ASSETID = "ASSETID" + case LOCATION = "LOCATION" + case TIMESTAMP = "TIMESTAMP" + case LIST = "LIST" + } + /// The CloudKit field type (optional, may be inferred from value) + /// + /// - Remark: Generated from `#/components/schemas/FieldValueResponse/type`. + public var _type: Components.Schemas.FieldValueResponse._typePayload? + /// Creates a new `FieldValueResponse`. + /// + /// - Parameters: + /// - value: + /// - _type: The CloudKit field type (optional, may be inferred from value) + public init( + value: Components.Schemas.FieldValueResponse.valuePayload, + _type: Components.Schemas.FieldValueResponse._typePayload? = nil + ) { + self.value = value + self._type = _type + } + public enum CodingKeys: String, CodingKey { + case value + case _type = "type" + } + } + /// A text string value + /// + /// - Remark: Generated from `#/components/schemas/StringValue`. + public typealias StringValue = Swift.String + /// A 64-bit integer value + /// + /// - Remark: Generated from `#/components/schemas/Int64Value`. + public typealias Int64Value = Swift.Int64 + /// A double-precision floating point value + /// + /// - Remark: Generated from `#/components/schemas/DoubleValue`. + public typealias DoubleValue = Swift.Double + /// Base64-encoded string representing binary data + /// + /// - Remark: Generated from `#/components/schemas/BytesValue`. + public typealias BytesValue = Swift.String + /// Number representing milliseconds since epoch (January 1, 1970) + /// + /// - Remark: Generated from `#/components/schemas/DateValue`. + public typealias DateValue = Swift.Double + /// Location dictionary as defined in CloudKit Web Services + /// + /// - Remark: Generated from `#/components/schemas/LocationValue`. + public struct LocationValue: Codable, Hashable, Sendable { + /// Latitude in degrees + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/latitude`. + public var latitude: Swift.Double? + /// Longitude in degrees + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/longitude`. + public var longitude: Swift.Double? + /// Horizontal accuracy in meters + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/horizontalAccuracy`. + public var horizontalAccuracy: Swift.Double? + /// Vertical accuracy in meters + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/verticalAccuracy`. + public var verticalAccuracy: Swift.Double? + /// Altitude in meters + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/altitude`. + public var altitude: Swift.Double? + /// Speed in meters per second + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/speed`. + public var speed: Swift.Double? + /// Course in degrees + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/course`. + public var course: Swift.Double? + /// Timestamp in milliseconds since epoch + /// + /// - Remark: Generated from `#/components/schemas/LocationValue/timestamp`. + public var timestamp: Swift.Double? + /// Creates a new `LocationValue`. + /// + /// - Parameters: + /// - latitude: Latitude in degrees + /// - longitude: Longitude in degrees + /// - horizontalAccuracy: Horizontal accuracy in meters + /// - verticalAccuracy: Vertical accuracy in meters + /// - altitude: Altitude in meters + /// - speed: Speed in meters per second + /// - course: Course in degrees + /// - timestamp: Timestamp in milliseconds since epoch + public init( + latitude: Swift.Double? = nil, + longitude: Swift.Double? = nil, + horizontalAccuracy: Swift.Double? = nil, + verticalAccuracy: Swift.Double? = nil, + altitude: Swift.Double? = nil, + speed: Swift.Double? = nil, + course: Swift.Double? = nil, + timestamp: Swift.Double? = nil + ) { + self.latitude = latitude + self.longitude = longitude + self.horizontalAccuracy = horizontalAccuracy + self.verticalAccuracy = verticalAccuracy + self.altitude = altitude + self.speed = speed + self.course = course + self.timestamp = timestamp + } + public enum CodingKeys: String, CodingKey { + case latitude + case longitude + case horizontalAccuracy + case verticalAccuracy + case altitude + case speed + case course + case timestamp + } + } + /// Reference dictionary as defined in CloudKit Web Services + /// + /// - Remark: Generated from `#/components/schemas/ReferenceValue`. + public struct ReferenceValue: Codable, Hashable, Sendable { + /// The record name being referenced + /// + /// - Remark: Generated from `#/components/schemas/ReferenceValue/recordName`. + public var recordName: Swift.String? + /// Action to perform on the referenced record + /// + /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. + @frozen public enum actionPayload: String, Codable, Hashable, Sendable, CaseIterable { + case NONE = "NONE" + case DELETE_SELF = "DELETE_SELF" + } + /// Action to perform on the referenced record + /// + /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. + public var action: Components.Schemas.ReferenceValue.actionPayload? + /// Creates a new `ReferenceValue`. + /// + /// - Parameters: + /// - recordName: The record name being referenced + /// - action: Action to perform on the referenced record + public init( + recordName: Swift.String? = nil, + action: Components.Schemas.ReferenceValue.actionPayload? = nil + ) { + self.recordName = recordName + self.action = action + } + public enum CodingKeys: String, CodingKey { + case recordName + case action + } + } + /// Asset dictionary as defined in CloudKit Web Services + /// + /// - Remark: Generated from `#/components/schemas/AssetValue`. + public struct AssetValue: Codable, Hashable, Sendable { + /// Checksum of the asset file + /// + /// - Remark: Generated from `#/components/schemas/AssetValue/fileChecksum`. + public var fileChecksum: Swift.String? + /// Size of the asset in bytes + /// + /// - Remark: Generated from `#/components/schemas/AssetValue/size`. + public var size: Swift.Int64? + /// Checksum of the asset reference + /// + /// - Remark: Generated from `#/components/schemas/AssetValue/referenceChecksum`. + public var referenceChecksum: Swift.String? + /// Wrapping key for the asset + /// + /// - Remark: Generated from `#/components/schemas/AssetValue/wrappingKey`. + public var wrappingKey: Swift.String? + /// Receipt for the asset + /// + /// - Remark: Generated from `#/components/schemas/AssetValue/receipt`. + public var receipt: Swift.String? + /// URL for downloading the asset + /// + /// - Remark: Generated from `#/components/schemas/AssetValue/downloadURL`. + public var downloadURL: Swift.String? + /// Creates a new `AssetValue`. + /// + /// - Parameters: + /// - fileChecksum: Checksum of the asset file + /// - size: Size of the asset in bytes + /// - referenceChecksum: Checksum of the asset reference + /// - wrappingKey: Wrapping key for the asset + /// - receipt: Receipt for the asset + /// - downloadURL: URL for downloading the asset + public init( + fileChecksum: Swift.String? = nil, + size: Swift.Int64? = nil, + referenceChecksum: Swift.String? = nil, + wrappingKey: Swift.String? = nil, + receipt: Swift.String? = nil, + downloadURL: Swift.String? = nil + ) { + self.fileChecksum = fileChecksum + self.size = size + self.referenceChecksum = referenceChecksum + self.wrappingKey = wrappingKey + self.receipt = receipt + self.downloadURL = downloadURL + } + public enum CodingKeys: String, CodingKey { + case fileChecksum + case size + case referenceChecksum + case wrappingKey + case receipt + case downloadURL + } + } + /// - Remark: Generated from `#/components/schemas/ListValue`. + @frozen public indirect enum ListValuePayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ListValue/case1`. + case StringValue(Components.Schemas.StringValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case2`. + case Int64Value(Components.Schemas.Int64Value) + /// - Remark: Generated from `#/components/schemas/ListValue/case3`. + case DoubleValue(Components.Schemas.DoubleValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case4`. + case BytesValue(Components.Schemas.BytesValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case5`. + case DateValue(Components.Schemas.DateValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case6`. + case LocationValue(Components.Schemas.LocationValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case7`. + case ReferenceValue(Components.Schemas.ReferenceValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case8`. + case AssetValue(Components.Schemas.AssetValue) + /// - Remark: Generated from `#/components/schemas/ListValue/case9`. + case ListValue(Components.Schemas.ListValue) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .StringValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .Int64Value(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .DoubleValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .BytesValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .DateValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .LocationValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ReferenceValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .AssetValue(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ListValue(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .StringValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .Int64Value(value): + try encoder.encodeToSingleValueContainer(value) + case let .DoubleValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .BytesValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .DateValue(value): + try encoder.encodeToSingleValueContainer(value) + case let .LocationValue(value): + try value.encode(to: encoder) + case let .ReferenceValue(value): + try value.encode(to: encoder) + case let .AssetValue(value): + try value.encode(to: encoder) + case let .ListValue(value): + try encoder.encodeToSingleValueContainer(value) + } + } + } + /// Array containing any of the above field types + /// + /// - Remark: Generated from `#/components/schemas/ListValue`. + public typealias ListValue = [Components.Schemas.ListValuePayload] + /// - Remark: Generated from `#/components/schemas/ZoneOperation`. + public struct ZoneOperation: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZoneOperation/operationType`. + @frozen public enum operationTypePayload: String, Codable, Hashable, Sendable, CaseIterable { + case create = "create" + case delete = "delete" + } + /// - Remark: Generated from `#/components/schemas/ZoneOperation/operationType`. + public var operationType: Components.Schemas.ZoneOperation.operationTypePayload? + /// - Remark: Generated from `#/components/schemas/ZoneOperation/zone`. + public struct zonePayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZoneOperation/zone/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Creates a new `zonePayload`. + /// + /// - Parameters: + /// - zoneID: + public init(zoneID: Components.Schemas.ZoneID? = nil) { + self.zoneID = zoneID + } + public enum CodingKeys: String, CodingKey { + case zoneID + } + } + /// - Remark: Generated from `#/components/schemas/ZoneOperation/zone`. + public var zone: Components.Schemas.ZoneOperation.zonePayload? + /// Creates a new `ZoneOperation`. + /// + /// - Parameters: + /// - operationType: + /// - zone: + public init( + operationType: Components.Schemas.ZoneOperation.operationTypePayload? = nil, + zone: Components.Schemas.ZoneOperation.zonePayload? = nil + ) { + self.operationType = operationType + self.zone = zone + } + public enum CodingKeys: String, CodingKey { + case operationType + case zone + } + } + /// - Remark: Generated from `#/components/schemas/SubscriptionOperation`. + public struct SubscriptionOperation: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SubscriptionOperation/operationType`. + @frozen public enum operationTypePayload: String, Codable, Hashable, Sendable, CaseIterable { + case create = "create" + case update = "update" + case delete = "delete" + } + /// - Remark: Generated from `#/components/schemas/SubscriptionOperation/operationType`. + public var operationType: Components.Schemas.SubscriptionOperation.operationTypePayload? + /// - Remark: Generated from `#/components/schemas/SubscriptionOperation/subscription`. + public var subscription: Components.Schemas.Subscription? + /// Creates a new `SubscriptionOperation`. + /// + /// - Parameters: + /// - operationType: + /// - subscription: + public init( + operationType: Components.Schemas.SubscriptionOperation.operationTypePayload? = nil, + subscription: Components.Schemas.Subscription? = nil + ) { + self.operationType = operationType + self.subscription = subscription + } + public enum CodingKeys: String, CodingKey { + case operationType + case subscription + } + } + /// - Remark: Generated from `#/components/schemas/Subscription`. + public struct Subscription: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionID`. + public var subscriptionID: Swift.String? + /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionType`. + @frozen public enum subscriptionTypePayload: String, Codable, Hashable, Sendable, CaseIterable { + case query = "query" + case zone = "zone" + } + /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionType`. + public var subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? + /// - Remark: Generated from `#/components/schemas/Subscription/query`. + public var query: OpenAPIRuntime.OpenAPIObjectContainer? + /// - Remark: Generated from `#/components/schemas/Subscription/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// - Remark: Generated from `#/components/schemas/Subscription/firesOnPayload`. + @frozen public enum firesOnPayloadPayload: String, Codable, Hashable, Sendable, CaseIterable { + case create = "create" + case update = "update" + case delete = "delete" + } + /// - Remark: Generated from `#/components/schemas/Subscription/firesOn`. + public typealias firesOnPayload = [Components.Schemas.Subscription.firesOnPayloadPayload] + /// - Remark: Generated from `#/components/schemas/Subscription/firesOn`. + public var firesOn: Components.Schemas.Subscription.firesOnPayload? + /// Creates a new `Subscription`. + /// + /// - Parameters: + /// - subscriptionID: + /// - subscriptionType: + /// - query: + /// - zoneID: + /// - firesOn: + public init( + subscriptionID: Swift.String? = nil, + subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? = nil, + query: OpenAPIRuntime.OpenAPIObjectContainer? = nil, + zoneID: Components.Schemas.ZoneID? = nil, + firesOn: Components.Schemas.Subscription.firesOnPayload? = nil + ) { + self.subscriptionID = subscriptionID + self.subscriptionType = subscriptionType + self.query = query + self.zoneID = zoneID + self.firesOn = firesOn + } + public enum CodingKeys: String, CodingKey { + case subscriptionID + case subscriptionType + case query + case zoneID + case firesOn + } + } + /// - Remark: Generated from `#/components/schemas/QueryResponse`. + public struct QueryResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/QueryResponse/records`. + public var records: [Components.Schemas.RecordResponse]? + /// - Remark: Generated from `#/components/schemas/QueryResponse/continuationMarker`. + public var continuationMarker: Swift.String? + /// Creates a new `QueryResponse`. + /// + /// - Parameters: + /// - records: + /// - continuationMarker: + public init( + records: [Components.Schemas.RecordResponse]? = nil, + continuationMarker: Swift.String? = nil + ) { + self.records = records + self.continuationMarker = continuationMarker + } + public enum CodingKeys: String, CodingKey { + case records + case continuationMarker + } + } + /// - Remark: Generated from `#/components/schemas/ModifyResponse`. + public struct ModifyResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ModifyResponse/records`. + public var records: [Components.Schemas.RecordResponse]? + /// Creates a new `ModifyResponse`. + /// + /// - Parameters: + /// - records: + public init(records: [Components.Schemas.RecordResponse]? = nil) { + self.records = records + } + public enum CodingKeys: String, CodingKey { + case records + } + } + /// - Remark: Generated from `#/components/schemas/LookupResponse`. + public struct LookupResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/LookupResponse/records`. + public var records: [Components.Schemas.RecordResponse]? + /// Creates a new `LookupResponse`. + /// + /// - Parameters: + /// - records: + public init(records: [Components.Schemas.RecordResponse]? = nil) { + self.records = records + } + public enum CodingKeys: String, CodingKey { + case records + } + } + /// - Remark: Generated from `#/components/schemas/ChangesResponse`. + public struct ChangesResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ChangesResponse/records`. + public var records: [Components.Schemas.RecordResponse]? + /// - Remark: Generated from `#/components/schemas/ChangesResponse/syncToken`. + public var syncToken: Swift.String? + /// - Remark: Generated from `#/components/schemas/ChangesResponse/moreComing`. + public var moreComing: Swift.Bool? + /// Creates a new `ChangesResponse`. + /// + /// - Parameters: + /// - records: + /// - syncToken: + /// - moreComing: + public init( + records: [Components.Schemas.RecordResponse]? = nil, + syncToken: Swift.String? = nil, + moreComing: Swift.Bool? = nil + ) { + self.records = records + self.syncToken = syncToken + self.moreComing = moreComing + } + public enum CodingKeys: String, CodingKey { + case records + case syncToken + case moreComing + } + } + /// - Remark: Generated from `#/components/schemas/ZonesListResponse`. + public struct ZonesListResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zonesPayload`. + public struct zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zonesPayload/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Creates a new `zonesPayloadPayload`. + /// + /// - Parameters: + /// - zoneID: + public init(zoneID: Components.Schemas.ZoneID? = nil) { + self.zoneID = zoneID + } + public enum CodingKeys: String, CodingKey { + case zoneID + } + } + /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zones`. + public typealias zonesPayload = [Components.Schemas.ZonesListResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zones`. + public var zones: Components.Schemas.ZonesListResponse.zonesPayload? + /// Creates a new `ZonesListResponse`. + /// + /// - Parameters: + /// - zones: + public init(zones: Components.Schemas.ZonesListResponse.zonesPayload? = nil) { + self.zones = zones + } + public enum CodingKeys: String, CodingKey { + case zones + } + } + /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse`. + public struct ZonesLookupResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zonesPayload`. + public struct zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zonesPayload/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Creates a new `zonesPayloadPayload`. + /// + /// - Parameters: + /// - zoneID: + public init(zoneID: Components.Schemas.ZoneID? = nil) { + self.zoneID = zoneID + } + public enum CodingKeys: String, CodingKey { + case zoneID + } + } + /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zones`. + public typealias zonesPayload = [Components.Schemas.ZonesLookupResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zones`. + public var zones: Components.Schemas.ZonesLookupResponse.zonesPayload? + /// Creates a new `ZonesLookupResponse`. + /// + /// - Parameters: + /// - zones: + public init(zones: Components.Schemas.ZonesLookupResponse.zonesPayload? = nil) { + self.zones = zones + } + public enum CodingKeys: String, CodingKey { + case zones + } + } + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse`. + public struct ZonesModifyResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload`. + public struct zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Creates a new `zonesPayloadPayload`. + /// + /// - Parameters: + /// - zoneID: + public init(zoneID: Components.Schemas.ZoneID? = nil) { + self.zoneID = zoneID + } + public enum CodingKeys: String, CodingKey { + case zoneID + } + } + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. + public typealias zonesPayload = [Components.Schemas.ZonesModifyResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. + public var zones: Components.Schemas.ZonesModifyResponse.zonesPayload? + /// Creates a new `ZonesModifyResponse`. + /// + /// - Parameters: + /// - zones: + public init(zones: Components.Schemas.ZonesModifyResponse.zonesPayload? = nil) { + self.zones = zones + } + public enum CodingKeys: String, CodingKey { + case zones + } + } + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse`. + public struct ZoneChangesResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zonesPayload`. + public struct zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zonesPayload/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Creates a new `zonesPayloadPayload`. + /// + /// - Parameters: + /// - zoneID: + public init(zoneID: Components.Schemas.ZoneID? = nil) { + self.zoneID = zoneID + } + public enum CodingKeys: String, CodingKey { + case zoneID + } + } + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. + public typealias zonesPayload = [Components.Schemas.ZoneChangesResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. + public var zones: Components.Schemas.ZoneChangesResponse.zonesPayload? + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/syncToken`. + public var syncToken: Swift.String? + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/moreComing`. + public var moreComing: Swift.Bool? + /// Creates a new `ZoneChangesResponse`. + /// + /// - Parameters: + /// - zones: + /// - syncToken: + /// - moreComing: + public init( + zones: Components.Schemas.ZoneChangesResponse.zonesPayload? = nil, + syncToken: Swift.String? = nil, + moreComing: Swift.Bool? = nil + ) { + self.zones = zones + self.syncToken = syncToken + self.moreComing = moreComing + } + public enum CodingKeys: String, CodingKey { + case zones + case syncToken + case moreComing + } + } + /// - Remark: Generated from `#/components/schemas/SubscriptionsListResponse`. + public struct SubscriptionsListResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SubscriptionsListResponse/subscriptions`. + public var subscriptions: [Components.Schemas.Subscription]? + /// Creates a new `SubscriptionsListResponse`. + /// + /// - Parameters: + /// - subscriptions: + public init(subscriptions: [Components.Schemas.Subscription]? = nil) { + self.subscriptions = subscriptions + } + public enum CodingKeys: String, CodingKey { + case subscriptions + } + } + /// - Remark: Generated from `#/components/schemas/SubscriptionsLookupResponse`. + public struct SubscriptionsLookupResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SubscriptionsLookupResponse/subscriptions`. + public var subscriptions: [Components.Schemas.Subscription]? + /// Creates a new `SubscriptionsLookupResponse`. + /// + /// - Parameters: + /// - subscriptions: + public init(subscriptions: [Components.Schemas.Subscription]? = nil) { + self.subscriptions = subscriptions + } + public enum CodingKeys: String, CodingKey { + case subscriptions + } + } + /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse`. + public struct SubscriptionsModifyResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptions`. + public var subscriptions: [Components.Schemas.Subscription]? + /// Creates a new `SubscriptionsModifyResponse`. + /// + /// - Parameters: + /// - subscriptions: + public init(subscriptions: [Components.Schemas.Subscription]? = nil) { + self.subscriptions = subscriptions + } + public enum CodingKeys: String, CodingKey { + case subscriptions + } + } + /// Timestamp information for record creation or modification + /// + /// - Remark: Generated from `#/components/schemas/RecordTimestamp`. + public struct RecordTimestamp: Codable, Hashable, Sendable { + /// Unix timestamp in milliseconds + /// + /// - Remark: Generated from `#/components/schemas/RecordTimestamp/timestamp`. + public var timestamp: Swift.Double? + /// Record name of the user who performed the action + /// + /// - Remark: Generated from `#/components/schemas/RecordTimestamp/userRecordName`. + public var userRecordName: Swift.String? + /// Creates a new `RecordTimestamp`. + /// + /// - Parameters: + /// - timestamp: Unix timestamp in milliseconds + /// - userRecordName: Record name of the user who performed the action + public init( + timestamp: Swift.Double? = nil, + userRecordName: Swift.String? = nil + ) { + self.timestamp = timestamp + self.userRecordName = userRecordName + } + public enum CodingKeys: String, CodingKey { + case timestamp + case userRecordName + } + } + /// The parts of a user's name + /// + /// - Remark: Generated from `#/components/schemas/NameComponents`. + public struct NameComponents: Codable, Hashable, Sendable { + /// The user's name prefix + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/namePrefix`. + public var namePrefix: Swift.String? + /// The user's first name + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/givenName`. + public var givenName: Swift.String? + /// The user's middle name + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/middleName`. + public var middleName: Swift.String? + /// The user's last name + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/familyName`. + public var familyName: Swift.String? + /// The user's name suffix + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/nameSuffix`. + public var nameSuffix: Swift.String? + /// The user's nickname + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/nickname`. + public var nickname: Swift.String? + /// A phonetic representation of the user's name + /// + /// - Remark: Generated from `#/components/schemas/NameComponents/phoneticRepresentation`. + public var phoneticRepresentation: Swift.String? + /// Creates a new `NameComponents`. + /// + /// - Parameters: + /// - namePrefix: The user's name prefix + /// - givenName: The user's first name + /// - middleName: The user's middle name + /// - familyName: The user's last name + /// - nameSuffix: The user's name suffix + /// - nickname: The user's nickname + /// - phoneticRepresentation: A phonetic representation of the user's name + public init( + namePrefix: Swift.String? = nil, + givenName: Swift.String? = nil, + middleName: Swift.String? = nil, + familyName: Swift.String? = nil, + nameSuffix: Swift.String? = nil, + nickname: Swift.String? = nil, + phoneticRepresentation: Swift.String? = nil + ) { + self.namePrefix = namePrefix + self.givenName = givenName + self.middleName = middleName + self.familyName = familyName + self.nameSuffix = nameSuffix + self.nickname = nickname + self.phoneticRepresentation = phoneticRepresentation + } + public enum CodingKeys: String, CodingKey { + case namePrefix + case givenName + case middleName + case familyName + case nameSuffix + case nickname + case phoneticRepresentation + } + } + /// Information used to look up a user identity + /// + /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo`. + public struct UserIdentityLookupInfo: Codable, Hashable, Sendable { + /// The user's email address + /// + /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo/emailAddress`. + public var emailAddress: Swift.String? + /// The user's phone number + /// + /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo/phoneNumber`. + public var phoneNumber: Swift.String? + /// The record name of the user + /// + /// - Remark: Generated from `#/components/schemas/UserIdentityLookupInfo/userRecordName`. + public var userRecordName: Swift.String? + /// Creates a new `UserIdentityLookupInfo`. + /// + /// - Parameters: + /// - emailAddress: The user's email address + /// - phoneNumber: The user's phone number + /// - userRecordName: The record name of the user + public init( + emailAddress: Swift.String? = nil, + phoneNumber: Swift.String? = nil, + userRecordName: Swift.String? = nil + ) { + self.emailAddress = emailAddress + self.phoneNumber = phoneNumber + self.userRecordName = userRecordName + } + public enum CodingKeys: String, CodingKey { + case emailAddress + case phoneNumber + case userRecordName + } + } + /// A user identity returned by discover endpoints + /// + /// - Remark: Generated from `#/components/schemas/UserIdentity`. + public struct UserIdentity: Codable, Hashable, Sendable { + /// The record name of the user + /// + /// - Remark: Generated from `#/components/schemas/UserIdentity/userRecordName`. + public var userRecordName: Swift.String? + /// - Remark: Generated from `#/components/schemas/UserIdentity/nameComponents`. + public var nameComponents: Components.Schemas.NameComponents? + /// - Remark: Generated from `#/components/schemas/UserIdentity/lookupInfo`. + public var lookupInfo: Components.Schemas.UserIdentityLookupInfo? + /// Creates a new `UserIdentity`. + /// + /// - Parameters: + /// - userRecordName: The record name of the user + /// - nameComponents: + /// - lookupInfo: + public init( + userRecordName: Swift.String? = nil, + nameComponents: Components.Schemas.NameComponents? = nil, + lookupInfo: Components.Schemas.UserIdentityLookupInfo? = nil + ) { + self.userRecordName = userRecordName + self.nameComponents = nameComponents + self.lookupInfo = lookupInfo + } + public enum CodingKeys: String, CodingKey { + case userRecordName + case nameComponents + case lookupInfo + } + } + /// A user returned by current/lookup endpoints (User Dictionary) + /// + /// - Remark: Generated from `#/components/schemas/UserResponse`. + public struct UserResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UserResponse/userRecordName`. + public var userRecordName: Swift.String? + /// - Remark: Generated from `#/components/schemas/UserResponse/firstName`. + public var firstName: Swift.String? + /// - Remark: Generated from `#/components/schemas/UserResponse/lastName`. + public var lastName: Swift.String? + /// - Remark: Generated from `#/components/schemas/UserResponse/emailAddress`. + public var emailAddress: Swift.String? + /// Creates a new `UserResponse`. + /// + /// - Parameters: + /// - userRecordName: + /// - firstName: + /// - lastName: + /// - emailAddress: + public init( + userRecordName: Swift.String? = nil, + firstName: Swift.String? = nil, + lastName: Swift.String? = nil, + emailAddress: Swift.String? = nil + ) { + self.userRecordName = userRecordName + self.firstName = firstName + self.lastName = lastName + self.emailAddress = emailAddress + } + public enum CodingKeys: String, CodingKey { + case userRecordName + case firstName + case lastName + case emailAddress + } + } + /// - Remark: Generated from `#/components/schemas/DiscoverResponse`. + public struct DiscoverResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/DiscoverResponse/users`. + public var users: [Components.Schemas.UserIdentity]? + /// Creates a new `DiscoverResponse`. + /// + /// - Parameters: + /// - users: + public init(users: [Components.Schemas.UserIdentity]? = nil) { + self.users = users + } + public enum CodingKeys: String, CodingKey { + case users + } + } + /// - Remark: Generated from `#/components/schemas/ContactsResponse`. + public struct ContactsResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ContactsResponse/contacts`. + public var contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? + /// Creates a new `ContactsResponse`. + /// + /// - Parameters: + /// - contacts: + public init(contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? = nil) { + self.contacts = contacts + } + public enum CodingKeys: String, CodingKey { + case contacts + } + } + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse`. + public struct AssetUploadResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload`. + public struct tokensPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload/url`. + public var url: Swift.String? + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload/recordName`. + public var recordName: Swift.String? + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokensPayload/fieldName`. + public var fieldName: Swift.String? + /// Creates a new `tokensPayloadPayload`. + /// + /// - Parameters: + /// - url: + /// - recordName: + /// - fieldName: + public init( + url: Swift.String? = nil, + recordName: Swift.String? = nil, + fieldName: Swift.String? = nil + ) { + self.url = url + self.recordName = recordName + self.fieldName = fieldName + } + public enum CodingKeys: String, CodingKey { + case url + case recordName + case fieldName + } + } + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokens`. + public typealias tokensPayload = [Components.Schemas.AssetUploadResponse.tokensPayloadPayload] + /// - Remark: Generated from `#/components/schemas/AssetUploadResponse/tokens`. + public var tokens: Components.Schemas.AssetUploadResponse.tokensPayload? + /// Creates a new `AssetUploadResponse`. + /// + /// - Parameters: + /// - tokens: + public init(tokens: Components.Schemas.AssetUploadResponse.tokensPayload? = nil) { + self.tokens = tokens + } + public enum CodingKeys: String, CodingKey { + case tokens + } + } + /// - Remark: Generated from `#/components/schemas/TokenResponse`. + public struct TokenResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/TokenResponse/apnsToken`. + public var apnsToken: Swift.String? + /// - Remark: Generated from `#/components/schemas/TokenResponse/webcAuthToken`. + public var webcAuthToken: Swift.String? + /// Creates a new `TokenResponse`. + /// + /// - Parameters: + /// - apnsToken: + /// - webcAuthToken: + public init( + apnsToken: Swift.String? = nil, + webcAuthToken: Swift.String? = nil + ) { + self.apnsToken = apnsToken + self.webcAuthToken = webcAuthToken + } + public enum CodingKeys: String, CodingKey { + case apnsToken + case webcAuthToken + } + } + /// Error response object. For a full list of error codes and meanings, see: + /// https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 + /// + /// Common error codes include: + /// - AUTHENTICATION_FAILED: The request could not be authenticated. + /// - ACCESS_DENIED: The user does not have permission to access the resource. + /// - INVALID_ARGUMENTS: The request contained invalid parameters. + /// - LIMIT_EXCEEDED: A request or resource limit was exceeded. + /// - NOT_FOUND: The requested resource does not exist. + /// - SERVICE_UNAVAILABLE: The service is temporarily unavailable. + /// - ZONE_NOT_FOUND: The specified zone does not exist. + /// - RECORD_NOT_FOUND: The specified record does not exist. + /// - PARTIAL_FAILURE: Some, but not all, operations succeeded. + /// + /// See the documentation for a complete list and details. + /// + /// + /// - Remark: Generated from `#/components/schemas/ErrorResponse`. + public struct ErrorResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ErrorResponse/uuid`. + public var uuid: Swift.String? + /// Server error code. See https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 for complete details. + /// + /// + /// - Remark: Generated from `#/components/schemas/ErrorResponse/serverErrorCode`. + @frozen public enum serverErrorCodePayload: String, Codable, Hashable, Sendable, CaseIterable { + case ACCESS_DENIED = "ACCESS_DENIED" + case ATOMIC_ERROR = "ATOMIC_ERROR" + case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" + case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" + case BAD_REQUEST = "BAD_REQUEST" + case CONFLICT = "CONFLICT" + case EXISTS = "EXISTS" + case INTERNAL_ERROR = "INTERNAL_ERROR" + case NOT_FOUND = "NOT_FOUND" + case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" + case THROTTLED = "THROTTLED" + case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" + case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" + case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" + } + /// Server error code. See https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 for complete details. + /// + /// + /// - Remark: Generated from `#/components/schemas/ErrorResponse/serverErrorCode`. + public var serverErrorCode: Components.Schemas.ErrorResponse.serverErrorCodePayload? + /// - Remark: Generated from `#/components/schemas/ErrorResponse/reason`. + public var reason: Swift.String? + /// - Remark: Generated from `#/components/schemas/ErrorResponse/redirectURL`. + public var redirectURL: Swift.String? + /// Creates a new `ErrorResponse`. + /// + /// - Parameters: + /// - uuid: + /// - serverErrorCode: Server error code. See https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 for complete details. + /// - reason: + /// - redirectURL: + public init( + uuid: Swift.String? = nil, + serverErrorCode: Components.Schemas.ErrorResponse.serverErrorCodePayload? = nil, + reason: Swift.String? = nil, + redirectURL: Swift.String? = nil + ) { + self.uuid = uuid + self.serverErrorCode = serverErrorCode + self.reason = reason + self.redirectURL = redirectURL + } + public enum CodingKeys: String, CodingKey { + case uuid + case serverErrorCode + case reason + case redirectURL + } + } + } + /// Types generated from the `#/components/parameters` section of the OpenAPI document. + public enum Parameters { + /// Protocol version + /// + /// - Remark: Generated from `#/components/parameters/version`. + public typealias version = Swift.String + /// Container ID (begins with "iCloud.") + /// + /// - Remark: Generated from `#/components/parameters/container`. + public typealias container = Swift.String + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + } + /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. + public enum RequestBodies {} + /// Types generated from the `#/components/responses` section of the OpenAPI document. + public enum Responses { + public struct Failure: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/Failure/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/Failure/content/application\/json`. + case json(Components.Schemas.ErrorResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ErrorResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Components.Responses.Failure.Body + /// Creates a new `Failure`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Components.Responses.Failure.Body) { + self.body = body + } + } + } + /// Types generated from the `#/components/headers` section of the OpenAPI document. + public enum Headers {} +} + +/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. +public enum Operations { + /// Query Records + /// + /// Fetch records using a query with filters and sorting options + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/query`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)`. + public enum queryRecords { + public static let id: Swift.String = "queryRecords" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.queryRecords.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.queryRecords.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Maximum number of records to return + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/resultsLimit`. + public var resultsLimit: Swift.Int? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query`. + public struct queryPayload: Codable, Hashable, Sendable { + /// The record type to query + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/recordType`. + public var recordType: Swift.String? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/filterBy`. + public var filterBy: [Components.Schemas.Filter]? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/sortBy`. + public var sortBy: [Components.Schemas.Sort]? + /// Creates a new `queryPayload`. + /// + /// - Parameters: + /// - recordType: The record type to query + /// - filterBy: + /// - sortBy: + public init( + recordType: Swift.String? = nil, + filterBy: [Components.Schemas.Filter]? = nil, + sortBy: [Components.Schemas.Sort]? = nil + ) { + self.recordType = recordType + self.filterBy = filterBy + self.sortBy = sortBy + } + public enum CodingKeys: String, CodingKey { + case recordType + case filterBy + case sortBy + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query`. + public var query: Operations.queryRecords.Input.Body.jsonPayload.queryPayload? + /// List of field names to return + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/desiredKeys`. + public var desiredKeys: [Swift.String]? + /// Marker for pagination + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/continuationMarker`. + public var continuationMarker: Swift.String? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - zoneID: + /// - resultsLimit: Maximum number of records to return + /// - query: + /// - desiredKeys: List of field names to return + /// - continuationMarker: Marker for pagination + public init( + zoneID: Components.Schemas.ZoneID? = nil, + resultsLimit: Swift.Int? = nil, + query: Operations.queryRecords.Input.Body.jsonPayload.queryPayload? = nil, + desiredKeys: [Swift.String]? = nil, + continuationMarker: Swift.String? = nil + ) { + self.zoneID = zoneID + self.resultsLimit = resultsLimit + self.query = query + self.desiredKeys = desiredKeys + self.continuationMarker = continuationMarker + } + public enum CodingKeys: String, CodingKey { + case zoneID + case resultsLimit + case query + case desiredKeys + case continuationMarker + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/content/application\/json`. + case json(Operations.queryRecords.Input.Body.jsonPayload) + } + public var body: Operations.queryRecords.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.queryRecords.Input.Path, + headers: Operations.queryRecords.Input.Headers = .init(), + body: Operations.queryRecords.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/responses/200/content/application\/json`. + case json(Components.Schemas.QueryResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.QueryResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.queryRecords.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.queryRecords.Output.Ok.Body) { + self.body = body + } + } + /// Successful query + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.queryRecords.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.queryRecords.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/query/post(queryRecords)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Modify Records + /// + /// Create, update, or delete records (supports bulk operations) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)`. + public enum modifyRecords { + public static let id: Swift.String = "modifyRecords" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.modifyRecords.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.modifyRecords.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/operations`. + public var operations: [Components.Schemas.RecordOperation]? + /// If true, all operations must succeed or all fail + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/atomic`. + public var atomic: Swift.Bool? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - operations: + /// - atomic: If true, all operations must succeed or all fail + public init( + operations: [Components.Schemas.RecordOperation]? = nil, + atomic: Swift.Bool? = nil + ) { + self.operations = operations + self.atomic = atomic + } + public enum CodingKeys: String, CodingKey { + case operations + case atomic + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/content/application\/json`. + case json(Operations.modifyRecords.Input.Body.jsonPayload) + } + public var body: Operations.modifyRecords.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.modifyRecords.Input.Path, + headers: Operations.modifyRecords.Input.Headers = .init(), + body: Operations.modifyRecords.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ModifyResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ModifyResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.modifyRecords.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.modifyRecords.Output.Ok.Body) { + self.body = body + } + } + /// Records modified successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.modifyRecords.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.modifyRecords.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/modify/post(modifyRecords)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Records + /// + /// Fetch specific records by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. + public enum lookupRecords { + public static let id: Swift.String = "lookupRecords" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.lookupRecords.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.lookupRecords.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/recordsPayload`. + public struct recordsPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/recordsPayload/recordName`. + public var recordName: Swift.String? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/recordsPayload/desiredKeys`. + public var desiredKeys: [Swift.String]? + /// Creates a new `recordsPayloadPayload`. + /// + /// - Parameters: + /// - recordName: + /// - desiredKeys: + public init( + recordName: Swift.String? = nil, + desiredKeys: [Swift.String]? = nil + ) { + self.recordName = recordName + self.desiredKeys = desiredKeys + } + public enum CodingKeys: String, CodingKey { + case recordName + case desiredKeys + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/records`. + public typealias recordsPayload = [Operations.lookupRecords.Input.Body.jsonPayload.recordsPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/json/records`. + public var records: Operations.lookupRecords.Input.Body.jsonPayload.recordsPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - records: + public init(records: Operations.lookupRecords.Input.Body.jsonPayload.recordsPayload? = nil) { + self.records = records + } + public enum CodingKeys: String, CodingKey { + case records + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/requestBody/content/application\/json`. + case json(Operations.lookupRecords.Input.Body.jsonPayload) + } + public var body: Operations.lookupRecords.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.lookupRecords.Input.Path, + headers: Operations.lookupRecords.Input.Headers = .init(), + body: Operations.lookupRecords.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/lookup/POST/responses/200/content/application\/json`. + case json(Components.Schemas.LookupResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.LookupResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.lookupRecords.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.lookupRecords.Output.Ok.Body) { + self.body = body + } + } + /// Records retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupRecords.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.lookupRecords.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Fetch Record Changes + /// + /// Get all record changes relative to a sync token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/changes`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)`. + public enum fetchRecordChanges { + public static let id: Swift.String = "fetchRecordChanges" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.fetchRecordChanges.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.fetchRecordChanges.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Token from previous sync operation + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/syncToken`. + public var syncToken: Swift.String? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/resultsLimit`. + public var resultsLimit: Swift.Int? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - zoneID: + /// - syncToken: Token from previous sync operation + /// - resultsLimit: + public init( + zoneID: Components.Schemas.ZoneID? = nil, + syncToken: Swift.String? = nil, + resultsLimit: Swift.Int? = nil + ) { + self.zoneID = zoneID + self.syncToken = syncToken + self.resultsLimit = resultsLimit + } + public enum CodingKeys: String, CodingKey { + case zoneID + case syncToken + case resultsLimit + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/content/application\/json`. + case json(Operations.fetchRecordChanges.Input.Body.jsonPayload) + } + public var body: Operations.fetchRecordChanges.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.fetchRecordChanges.Input.Path, + headers: Operations.fetchRecordChanges.Input.Headers = .init(), + body: Operations.fetchRecordChanges.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ChangesResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ChangesResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.fetchRecordChanges.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.fetchRecordChanges.Output.Ok.Body) { + self.body = body + } + } + /// Changes retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.fetchRecordChanges.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.fetchRecordChanges.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/changes/post(fetchRecordChanges)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// List All Zones + /// + /// Fetch all zones in the database + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/zones/list`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)`. + public enum listZones { + public static let id: Swift.String = "listZones" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.listZones.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.listZones.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.listZones.Input.Path, + headers: Operations.listZones.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/list/GET/responses/200/content/application\/json`. + case json(Components.Schemas.ZonesListResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ZonesListResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.listZones.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.listZones.Output.Ok.Body) { + self.body = body + } + } + /// Zones retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.listZones.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.listZones.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/list/get(listZones)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Zones + /// + /// Fetch specific zones by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)`. + public enum lookupZones { + public static let id: Swift.String = "lookupZones" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.lookupZones.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.lookupZones.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody/json/zones`. + public var zones: [Components.Schemas.ZoneID]? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - zones: + public init(zones: [Components.Schemas.ZoneID]? = nil) { + self.zones = zones + } + public enum CodingKeys: String, CodingKey { + case zones + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/requestBody/content/application\/json`. + case json(Operations.lookupZones.Input.Body.jsonPayload) + } + public var body: Operations.lookupZones.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.lookupZones.Input.Path, + headers: Operations.lookupZones.Input.Headers = .init(), + body: Operations.lookupZones.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/lookup/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ZonesLookupResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ZonesLookupResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.lookupZones.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.lookupZones.Output.Ok.Body) { + self.body = body + } + } + /// Zones retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupZones.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.lookupZones.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/lookup/post(lookupZones)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Modify Zones + /// + /// Create or delete zones (only supported in private database) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. + public enum modifyZones { + public static let id: Swift.String = "modifyZones" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.modifyZones.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.modifyZones.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody/json/operations`. + public var operations: [Components.Schemas.ZoneOperation]? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - operations: + public init(operations: [Components.Schemas.ZoneOperation]? = nil) { + self.operations = operations + } + public enum CodingKeys: String, CodingKey { + case operations + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/requestBody/content/application\/json`. + case json(Operations.modifyZones.Input.Body.jsonPayload) + } + public var body: Operations.modifyZones.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.modifyZones.Input.Path, + headers: Operations.modifyZones.Input.Headers = .init(), + body: Operations.modifyZones.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/modify/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ZonesModifyResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ZonesModifyResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.modifyZones.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.modifyZones.Output.Ok.Body) { + self.body = body + } + } + /// Zones modified successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.modifyZones.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.modifyZones.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Fetch Zone Changes + /// + /// Get all changed zones relative to a meta-sync token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. + public enum fetchZoneChanges { + public static let id: Swift.String = "fetchZoneChanges" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.fetchZoneChanges.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.fetchZoneChanges.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// Meta-sync token from previous operation + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json/syncToken`. + public var syncToken: Swift.String? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - syncToken: Meta-sync token from previous operation + public init(syncToken: Swift.String? = nil) { + self.syncToken = syncToken + } + public enum CodingKeys: String, CodingKey { + case syncToken + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/content/application\/json`. + case json(Operations.fetchZoneChanges.Input.Body.jsonPayload) + } + public var body: Operations.fetchZoneChanges.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.fetchZoneChanges.Input.Path, + headers: Operations.fetchZoneChanges.Input.Headers = .init(), + body: Operations.fetchZoneChanges.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ZoneChangesResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ZoneChangesResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.fetchZoneChanges.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.fetchZoneChanges.Output.Ok.Body) { + self.body = body + } + } + /// Zone changes retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.fetchZoneChanges.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.fetchZoneChanges.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// List All Subscriptions + /// + /// Fetch all subscriptions in the database + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/subscriptions/list`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)`. + public enum listSubscriptions { + public static let id: Swift.String = "listSubscriptions" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.listSubscriptions.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.listSubscriptions.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.listSubscriptions.Input.Path, + headers: Operations.listSubscriptions.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/list/GET/responses/200/content/application\/json`. + case json(Components.Schemas.SubscriptionsListResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.SubscriptionsListResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.listSubscriptions.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.listSubscriptions.Output.Ok.Body) { + self.body = body + } + } + /// Subscriptions retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.listSubscriptions.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.listSubscriptions.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/list/get(listSubscriptions)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Subscriptions + /// + /// Fetch specific subscriptions by their IDs + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/lookup`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)`. + public enum lookupSubscriptions { + public static let id: Swift.String = "lookupSubscriptions" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.lookupSubscriptions.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.lookupSubscriptions.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptionsPayload`. + public struct subscriptionsPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptionsPayload/subscriptionID`. + public var subscriptionID: Swift.String? + /// Creates a new `subscriptionsPayloadPayload`. + /// + /// - Parameters: + /// - subscriptionID: + public init(subscriptionID: Swift.String? = nil) { + self.subscriptionID = subscriptionID + } + public enum CodingKeys: String, CodingKey { + case subscriptionID + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptions`. + public typealias subscriptionsPayload = [Operations.lookupSubscriptions.Input.Body.jsonPayload.subscriptionsPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/json/subscriptions`. + public var subscriptions: Operations.lookupSubscriptions.Input.Body.jsonPayload.subscriptionsPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - subscriptions: + public init(subscriptions: Operations.lookupSubscriptions.Input.Body.jsonPayload.subscriptionsPayload? = nil) { + self.subscriptions = subscriptions + } + public enum CodingKeys: String, CodingKey { + case subscriptions + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/requestBody/content/application\/json`. + case json(Operations.lookupSubscriptions.Input.Body.jsonPayload) + } + public var body: Operations.lookupSubscriptions.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.lookupSubscriptions.Input.Path, + headers: Operations.lookupSubscriptions.Input.Headers = .init(), + body: Operations.lookupSubscriptions.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/lookup/POST/responses/200/content/application\/json`. + case json(Components.Schemas.SubscriptionsLookupResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.SubscriptionsLookupResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.lookupSubscriptions.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.lookupSubscriptions.Output.Ok.Body) { + self.body = body + } + } + /// Subscriptions retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupSubscriptions.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.lookupSubscriptions.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/lookup/post(lookupSubscriptions)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Modify Subscriptions + /// + /// Create, update, or delete subscriptions + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. + public enum modifySubscriptions { + public static let id: Swift.String = "modifySubscriptions" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.modifySubscriptions.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.modifySubscriptions.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody/json/operations`. + public var operations: [Components.Schemas.SubscriptionOperation]? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - operations: + public init(operations: [Components.Schemas.SubscriptionOperation]? = nil) { + self.operations = operations + } + public enum CodingKeys: String, CodingKey { + case operations + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/requestBody/content/application\/json`. + case json(Operations.modifySubscriptions.Input.Body.jsonPayload) + } + public var body: Operations.modifySubscriptions.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.modifySubscriptions.Input.Path, + headers: Operations.modifySubscriptions.Input.Headers = .init(), + body: Operations.modifySubscriptions.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/subscriptions/modify/POST/responses/200/content/application\/json`. + case json(Components.Schemas.SubscriptionsModifyResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.SubscriptionsModifyResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.modifySubscriptions.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.modifySubscriptions.Output.Ok.Body) { + self.body = body + } + } + /// Subscriptions modified successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.modifySubscriptions.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.modifySubscriptions.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Get the Caller (Current User) + /// + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + public enum getCaller { + public static let id: Swift.String = "getCaller" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.getCaller.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getCaller.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getCaller.Input.Path, + headers: Operations.getCaller.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/responses/200/content/application\/json`. + case json(Components.Schemas.UserResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.UserResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getCaller.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getCaller.Output.Ok.Body) { + self.body = body + } + } + /// User information retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getCaller.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getCaller.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + public enum discoverAllUserIdentities { + public static let id: Swift.String = "discoverAllUserIdentities" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.discoverAllUserIdentities.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.discoverAllUserIdentities.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.discoverAllUserIdentities.Input.Path, + headers: Operations.discoverAllUserIdentities.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.discoverAllUserIdentities.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.discoverAllUserIdentities.Output.Ok.Body) { + self.body = body + } + } + /// All discoverable user identities returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.discoverAllUserIdentities.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.discoverAllUserIdentities.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Discover User Identities + /// + /// Discover all user identities based on email addresses or user record names + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. + public enum discoverUserIdentities { + public static let id: Swift.String = "discoverUserIdentities" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.discoverUserIdentities.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.discoverUserIdentities.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload`. + public struct lookupInfosPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload/emailAddress`. + public var emailAddress: Swift.String? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload/phoneNumber`. + public var phoneNumber: Swift.String? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfosPayload/userRecordName`. + public var userRecordName: Swift.String? + /// Creates a new `lookupInfosPayloadPayload`. + /// + /// - Parameters: + /// - emailAddress: + /// - phoneNumber: + /// - userRecordName: + public init( + emailAddress: Swift.String? = nil, + phoneNumber: Swift.String? = nil, + userRecordName: Swift.String? = nil + ) { + self.emailAddress = emailAddress + self.phoneNumber = phoneNumber + self.userRecordName = userRecordName + } + public enum CodingKeys: String, CodingKey { + case emailAddress + case phoneNumber + case userRecordName + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfos`. + public typealias lookupInfosPayload = [Operations.discoverUserIdentities.Input.Body.jsonPayload.lookupInfosPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/json/lookupInfos`. + public var lookupInfos: Operations.discoverUserIdentities.Input.Body.jsonPayload.lookupInfosPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - lookupInfos: + public init(lookupInfos: Operations.discoverUserIdentities.Input.Body.jsonPayload.lookupInfosPayload? = nil) { + self.lookupInfos = lookupInfos + } + public enum CodingKeys: String, CodingKey { + case lookupInfos + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/requestBody/content/application\/json`. + case json(Operations.discoverUserIdentities.Input.Body.jsonPayload) + } + public var body: Operations.discoverUserIdentities.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.discoverUserIdentities.Input.Path, + headers: Operations.discoverUserIdentities.Input.Headers = .init(), + body: Operations.discoverUserIdentities.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/POST/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.discoverUserIdentities.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.discoverUserIdentities.Output.Ok.Body) { + self.body = body + } + } + /// User identities discovered successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.discoverUserIdentities.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.discoverUserIdentities.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + public enum lookupUsersByEmail { + public static let id: Swift.String = "lookupUsersByEmail" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.lookupUsersByEmail.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.lookupUsersByEmail.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/usersPayload`. + public struct usersPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/usersPayload/emailAddress`. + public var emailAddress: Swift.String? + /// Creates a new `usersPayloadPayload`. + /// + /// - Parameters: + /// - emailAddress: + public init(emailAddress: Swift.String? = nil) { + self.emailAddress = emailAddress + } + public enum CodingKeys: String, CodingKey { + case emailAddress + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/users`. + public typealias usersPayload = [Operations.lookupUsersByEmail.Input.Body.jsonPayload.usersPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/users`. + public var users: Operations.lookupUsersByEmail.Input.Body.jsonPayload.usersPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - users: + public init(users: Operations.lookupUsersByEmail.Input.Body.jsonPayload.usersPayload? = nil) { + self.users = users + } + public enum CodingKeys: String, CodingKey { + case users + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/content/application\/json`. + case json(Operations.lookupUsersByEmail.Input.Body.jsonPayload) + } + public var body: Operations.lookupUsersByEmail.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.lookupUsersByEmail.Input.Path, + headers: Operations.lookupUsersByEmail.Input.Headers = .init(), + body: Operations.lookupUsersByEmail.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.lookupUsersByEmail.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.lookupUsersByEmail.Output.Ok.Body) { + self.body = body + } + } + /// User identities returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupUsersByEmail.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.lookupUsersByEmail.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + public enum lookupUsersByRecordName { + public static let id: Swift.String = "lookupUsersByRecordName" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.lookupUsersByRecordName.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.lookupUsersByRecordName.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/usersPayload`. + public struct usersPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/usersPayload/userRecordName`. + public var userRecordName: Swift.String? + /// Creates a new `usersPayloadPayload`. + /// + /// - Parameters: + /// - userRecordName: + public init(userRecordName: Swift.String? = nil) { + self.userRecordName = userRecordName + } + public enum CodingKeys: String, CodingKey { + case userRecordName + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/users`. + public typealias usersPayload = [Operations.lookupUsersByRecordName.Input.Body.jsonPayload.usersPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/users`. + public var users: Operations.lookupUsersByRecordName.Input.Body.jsonPayload.usersPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - users: + public init(users: Operations.lookupUsersByRecordName.Input.Body.jsonPayload.usersPayload? = nil) { + self.users = users + } + public enum CodingKeys: String, CodingKey { + case users + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/content/application\/json`. + case json(Operations.lookupUsersByRecordName.Input.Body.jsonPayload) + } + public var body: Operations.lookupUsersByRecordName.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.lookupUsersByRecordName.Input.Path, + headers: Operations.lookupUsersByRecordName.Input.Headers = .init(), + body: Operations.lookupUsersByRecordName.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.lookupUsersByRecordName.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.lookupUsersByRecordName.Output.Ok.Body) { + self.body = body + } + } + /// User identities returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupUsersByRecordName.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.lookupUsersByRecordName.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Contacts (Deprecated) + /// + /// Fetch contacts (This endpoint is deprecated) + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/contacts`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)`. + public enum lookupContacts { + public static let id: Swift.String = "lookupContacts" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.lookupContacts.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.lookupContacts.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody/json/contacts`. + public var contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - contacts: + public init(contacts: [OpenAPIRuntime.OpenAPIObjectContainer]? = nil) { + self.contacts = contacts + } + public enum CodingKeys: String, CodingKey { + case contacts + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/requestBody/content/application\/json`. + case json(Operations.lookupContacts.Input.Body.jsonPayload) + } + public var body: Operations.lookupContacts.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.lookupContacts.Input.Path, + headers: Operations.lookupContacts.Input.Headers = .init(), + body: Operations.lookupContacts.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/contacts/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ContactsResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ContactsResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.lookupContacts.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.lookupContacts.Output.Ok.Body) { + self.body = body + } + } + /// Contacts retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupContacts.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.lookupContacts.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/contacts/post(lookupContacts)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Request Asset Upload URLs + /// + /// Request upload URLs for asset fields. This is the first step in a two-step process: + /// 1. Request upload URLs by specifying the record type and field name + /// 2. Upload the actual binary data to the returned URL (separate HTTP request) + /// + /// Upload URLs are valid for 15 minutes. Maximum file size is 15 MB. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/assets/upload`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)`. + public enum uploadAssets { + public static let id: Swift.String = "uploadAssets" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.uploadAssets.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.uploadAssets.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload`. + public struct tokensPayloadPayload: Codable, Hashable, Sendable { + /// Unique name to identify the record. Defaults to random UUID if not specified. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload/recordName`. + public var recordName: Swift.String? + /// Name of the record type + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload/recordType`. + public var recordType: Swift.String + /// Name of the Asset or Asset list field + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokensPayload/fieldName`. + public var fieldName: Swift.String + /// Creates a new `tokensPayloadPayload`. + /// + /// - Parameters: + /// - recordName: Unique name to identify the record. Defaults to random UUID if not specified. + /// - recordType: Name of the record type + /// - fieldName: Name of the Asset or Asset list field + public init( + recordName: Swift.String? = nil, + recordType: Swift.String, + fieldName: Swift.String + ) { + self.recordName = recordName + self.recordType = recordType + self.fieldName = fieldName + } + public enum CodingKeys: String, CodingKey { + case recordName + case recordType + case fieldName + } + } + /// Array of asset fields to request upload URLs for + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokens`. + public typealias tokensPayload = [Operations.uploadAssets.Input.Body.jsonPayload.tokensPayloadPayload] + /// Array of asset fields to request upload URLs for + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/json/tokens`. + public var tokens: Operations.uploadAssets.Input.Body.jsonPayload.tokensPayload + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - zoneID: + /// - tokens: Array of asset fields to request upload URLs for + public init( + zoneID: Components.Schemas.ZoneID? = nil, + tokens: Operations.uploadAssets.Input.Body.jsonPayload.tokensPayload + ) { + self.zoneID = zoneID + self.tokens = tokens + } + public enum CodingKeys: String, CodingKey { + case zoneID + case tokens + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/requestBody/content/application\/json`. + case json(Operations.uploadAssets.Input.Body.jsonPayload) + } + public var body: Operations.uploadAssets.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.uploadAssets.Input.Path, + headers: Operations.uploadAssets.Input.Headers = .init(), + body: Operations.uploadAssets.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/assets/upload/POST/responses/200/content/application\/json`. + case json(Components.Schemas.AssetUploadResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.AssetUploadResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.uploadAssets.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.uploadAssets.Output.Ok.Body) { + self.body = body + } + } + /// Upload URLs returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.uploadAssets.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.uploadAssets.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/assets/upload/post(uploadAssets)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Create APNs Token + /// + /// Create an Apple Push Notification service (APNs) token + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + public enum createToken { + public static let id: Swift.String = "createToken" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.createToken.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.createToken.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json/apnsEnvironment`. + @frozen public enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json/apnsEnvironment`. + public var apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - apnsEnvironment: + public init(apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? = nil) { + self.apnsEnvironment = apnsEnvironment + } + public enum CodingKeys: String, CodingKey { + case apnsEnvironment + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/content/application\/json`. + case json(Operations.createToken.Input.Body.jsonPayload) + } + public var body: Operations.createToken.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.createToken.Input.Path, + headers: Operations.createToken.Input.Headers = .init(), + body: Operations.createToken.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/responses/200/content/application\/json`. + case json(Components.Schemas.TokenResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.TokenResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.createToken.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.createToken.Output.Ok.Body) { + self.body = body + } + } + /// Token created successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.createToken.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.createToken.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Register Token + /// + /// Register a token for push notifications + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + public enum registerToken { + public static let id: Swift.String = "registerToken" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.registerToken.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.registerToken.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// The APNs token to register + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/json/apnsToken`. + public var apnsToken: Swift.String? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - apnsToken: The APNs token to register + public init(apnsToken: Swift.String? = nil) { + self.apnsToken = apnsToken + } + public enum CodingKeys: String, CodingKey { + case apnsToken + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/content/application\/json`. + case json(Operations.registerToken.Input.Body.jsonPayload) + } + public var body: Operations.registerToken.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.registerToken.Input.Path, + headers: Operations.registerToken.Input.Headers = .init(), + body: Operations.registerToken.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// Token registered successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.registerToken.Output.Ok) + /// Token registered successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.registerToken.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } +} diff --git a/Tests/MistKitTests/AdaptiveTokenManager/AdaptiveTokenManager+TestHelpers.swift b/Tests/MistKitTests/AdaptiveTokenManager/AdaptiveTokenManager+TestHelpers.swift deleted file mode 100644 index 938cfd0c..00000000 --- a/Tests/MistKitTests/AdaptiveTokenManager/AdaptiveTokenManager+TestHelpers.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension AdaptiveTokenManager { - /// Test helper to validate credentials and return a boolean result - internal func validateManager() async -> Bool { - do { - return try await validateCredentials() - } catch { - return false - } - } - - /// Test helper to get credentials and return them or nil - internal func getCredentialsFromManager() async -> TokenCredentials? { - do { - return try await getCurrentCredentials() - } catch { - return nil - } - } - - /// Test helper to check if credentials are available - internal func checkHasCredentials() async -> Bool { - await hasCredentials - } -} diff --git a/Tests/MistKitTests/Authentication/APIToken/APITokenAuthenticatorTests.swift b/Tests/MistKitTests/Authentication/APIToken/APITokenAuthenticatorTests.swift new file mode 100644 index 00000000..7187aa71 --- /dev/null +++ b/Tests/MistKitTests/Authentication/APIToken/APITokenAuthenticatorTests.swift @@ -0,0 +1,107 @@ +// +// APITokenAuthenticatorTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +/// Per-authenticator tests for `APITokenAuthenticator` — request mutation, +/// format validation in init, and serialization round-trip. +@Suite("APITokenAuthenticator") +internal struct APITokenAuthenticatorTests { + // MARK: - authenticate(request:body:) + + @Test("authenticate appends ckAPIToken query item") + internal func appendsAPITokenQueryItem() async throws { + let authenticator = try APITokenAuthenticator(token: TestConstants.apiToken) + var request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example/development/public/records/query" + ) + var body: HTTPBody? + + try await authenticator.authenticate(request: &request, body: &body) + + let path = try #require(request.path) + #expect(path.contains("ckAPIToken=\(TestConstants.apiToken)")) + } + + @Test("authenticate preserves existing query items") + internal func preservesExistingQuery() async throws { + let authenticator = try APITokenAuthenticator(token: TestConstants.apiToken) + var request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/foo/bar?existing=value" + ) + var body: HTTPBody? + + try await authenticator.authenticate(request: &request, body: &body) + + let path = try #require(request.path) + #expect(path.contains("existing=value")) + #expect(path.contains("ckAPIToken=\(TestConstants.apiToken)")) + } + + // MARK: - init validation + + @Test("init throws on empty token") + internal func emptyTokenThrows() { + do { + _ = try APITokenAuthenticator(token: "") + Issue.record("Expected init to throw") + } catch { + if case .invalidCredentials(.apiTokenEmpty) = error { + // Expected + } else { + Issue.record("Unexpected error: \(error)") + } + } + } + + @Test("init throws on malformed token") + internal func malformedTokenThrows() { + do { + _ = try APITokenAuthenticator(token: "not-a-valid-token") + Issue.record("Expected init to throw") + } catch { + if case .invalidCredentials(.apiTokenInvalidFormat) = error { + // Expected + } else { + Issue.record("Unexpected error: \(error)") + } + } + } + + // MARK: - serialization round-trip + + @Test("encoded then init(decoding:) round-trips token") + internal func encodingRoundTrip() throws { + let original = try APITokenAuthenticator(token: TestConstants.apiToken) + let data = try original.encoded() + let restored = try APITokenAuthenticator(decoding: data) + #expect(restored.token == original.token) + } + + @Test("storageKey is stable") + internal func storageKey() { + #expect(APITokenAuthenticator.storageKey == "api-token") + } + + @Test("defaultStorageIdentifier uses token prefix") + internal func defaultStorageIdentifier() throws { + let authenticator = try APITokenAuthenticator(token: TestConstants.apiToken) + #expect(authenticator.defaultStorageIdentifier == "api-\(TestConstants.apiToken.prefix(8))") + } +} diff --git a/Tests/MistKitTests/Authentication/APIToken/APITokenManager+TestHelpers.swift b/Tests/MistKitTests/Authentication/APIToken/APITokenManager+TestHelpers.swift index 96769596..fb64a1f5 100644 --- a/Tests/MistKitTests/Authentication/APIToken/APITokenManager+TestHelpers.swift +++ b/Tests/MistKitTests/Authentication/APIToken/APITokenManager+TestHelpers.swift @@ -13,10 +13,10 @@ extension APITokenManager { } } - /// Test helper to get credentials and return them or nil - internal func getCredentialsFromManager() async -> TokenCredentials? { + /// Test helper to get the current authenticator or nil on failure. + internal func authenticatorFromManager() async -> (any Authenticator)? { do { - return try await getCurrentCredentials() + return try await currentAuthenticator() } catch { return nil } diff --git a/Tests/MistKitTests/Authentication/APIToken/APITokenManagerMetadataTests.swift b/Tests/MistKitTests/Authentication/APIToken/APITokenManagerMetadataTests.swift deleted file mode 100644 index 2f39e729..00000000 --- a/Tests/MistKitTests/Authentication/APIToken/APITokenManagerMetadataTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("API Token Manager Metadata") -internal enum APITokenManagerMetadataTests {} - -extension APITokenManagerMetadataTests { - /// Metadata and sendable compliance tests for APITokenManager - @Suite("Metadata Tests") - internal struct MetadataTests { - // MARK: - Metadata Tests - - /// Tests credentialsWithMetadata method - @Test("credentialsWithMetadata method") - internal func credentialsWithMetadata() { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - let metadata = ["created": "2025-01-01", "environment": "test"] - let credentials = manager.credentialsWithMetadata(metadata) - - if case .apiToken(let token) = credentials.method { - #expect(token == validToken) - } else { - Issue.record("Expected .apiToken method") - } - - #expect(credentials.metadata["created"] == "2025-01-01") - #expect(credentials.metadata["environment"] == "test") - } - - /// Tests credentialsWithMetadata with empty metadata - @Test("credentialsWithMetadata with empty metadata") - internal func credentialsWithEmptyMetadata() { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - let credentials = manager.credentialsWithMetadata([:]) - - if case .apiToken(let token) = credentials.method { - #expect(token == validToken) - } else { - Issue.record("Expected .apiToken method") - } - - #expect(credentials.metadata.isEmpty) - } - - // MARK: - Sendable Compliance Tests - - /// Tests that APITokenManager can be used across async boundaries - @Test("APITokenManager sendable compliance") - internal func sendableCompliance() async { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - // Test concurrent access patterns - async let task1 = manager.validateManager() - async let task2 = manager.getCredentialsFromManager() - async let task3 = manager.checkHasCredentials() - - let results = await (task1, task2, task3) - #expect(results.0 == true) - #expect(results.1 != nil) - #expect(results.2 == true) - } - } -} diff --git a/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests+Manager.swift b/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests+Manager.swift new file mode 100644 index 00000000..6f0fb948 --- /dev/null +++ b/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests+Manager.swift @@ -0,0 +1,214 @@ +import Foundation +import Testing + +@testable import MistKit + +internal enum APITokenManagerTests {} + +extension APITokenManagerTests { + /// Test suite for APITokenManager functionality + @Suite("API Token Manager") + internal struct Manager { + // MARK: - Initialization Tests + + /// Tests APITokenManager initialization with valid API token + @Test("APITokenManager initialization with valid API token") + internal func initializationValidToken() { + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + + #expect(manager.token == validToken) + #expect(manager.isValidFormat == true) + } + + /// Tests APITokenManager initialization with invalid API token format + @Test("APITokenManager initialization with invalid API token format") + internal func initializationInvalidToken() { + let invalidToken = "invalid_token_format" + let manager = APITokenManager(apiToken: invalidToken) + + #expect(manager.token == invalidToken) + #expect(manager.isValidFormat == false) + } + + /// Tests APITokenManager initialization with empty token (should crash) + @Test("APITokenManager initialization with empty token") + internal func initializationEmptyToken() { + _ = "" + + // This should crash due to precondition - we can't easily test this with Swift Testing + // Instead, we'll test that a valid token works + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + #expect(manager.token == validToken) + } + + // MARK: - TokenManager Protocol Tests + + /// Tests hasCredentials property + @Test("hasCredentials property") + internal func hasCredentials() async { + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + + let hasCredentials = await manager.hasCredentials + #expect(hasCredentials == true) + } + + /// Tests validateCredentials with valid token + @Test("validateCredentials with valid token") + internal func validateCredentialsValidToken() async throws { + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + + let isValid = try await manager.validateCredentials() + #expect(isValid == true) + } + + /// Tests validateCredentials with invalid token + @Test("validateCredentials with invalid token") + internal func validateCredentialsInvalidToken() async throws { + let invalidToken = "invalid_token_format" + let manager = APITokenManager(apiToken: invalidToken) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(let reason): + if case .apiTokenInvalidFormat = reason { + // Expected case + } else { + Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") + } + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests validateCredentials with token that's too short + @Test("validateCredentials with token that's too short") + internal func validateCredentialsShortToken() async throws { + let shortToken = "abc123" + let manager = APITokenManager(apiToken: shortToken) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(let reason): + if case .apiTokenInvalidFormat = reason { + // Expected case + } else { + Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") + } + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests validateCredentials with token that's too long + @Test("validateCredentials with token that's too long") + internal func validateCredentialsLongToken() async throws { + let longToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd12345" + let manager = APITokenManager(apiToken: longToken) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(let reason): + if case .apiTokenInvalidFormat = reason { + // Expected case + } else { + Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") + } + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests validateCredentials with non-hex characters + @Test("validateCredentials with non-hex characters") + internal func validateCredentialsNonHexToken() async throws { + let nonHexToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd12gh" + let manager = APITokenManager(apiToken: nonHexToken) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(let reason): + if case .apiTokenInvalidFormat = reason { + // Expected case + } else { + Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") + } + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests currentAuthenticator with valid token + @Test("currentAuthenticator with valid token") + internal func currentAuthenticatorValidToken() async throws { + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + + let authenticator = try await manager.currentAuthenticator() + let api = try #require(authenticator as? APITokenAuthenticator) + #expect(api.token == validToken) + } + + /// Tests currentAuthenticator with invalid token + @Test("currentAuthenticator with invalid token") + internal func currentAuthenticatorInvalidToken() async throws { + let invalidToken = "invalid_token_format" + let manager = APITokenManager(apiToken: invalidToken) + + do { + _ = try await manager.currentAuthenticator() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(let reason): + if case .apiTokenInvalidFormat = reason { + // Expected case + } else { + Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") + } + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + // MARK: - Extension Methods Tests + + /// Tests isValidFormat property with valid token + @Test("isValidFormat property with valid token") + internal func isValidFormatValidToken() { + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + + #expect(manager.isValidFormat == true) + } + + /// Tests isValidFormat property with invalid token + @Test("isValidFormat property with invalid token") + internal func isValidFormatInvalidToken() { + let invalidToken = "invalid_token_format" + let manager = APITokenManager(apiToken: invalidToken) + + #expect(manager.isValidFormat == false) + } + } +} diff --git a/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests+Metadata.swift b/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests+Metadata.swift new file mode 100644 index 00000000..7de63cb7 --- /dev/null +++ b/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests+Metadata.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing + +@testable import MistKit + +extension APITokenManagerTests { + /// Sendable compliance tests for APITokenManager. + @Suite("API Token Manager Sendable") + internal struct Metadata { + // MARK: - Sendable Compliance Tests + + /// Tests that APITokenManager can be used across async boundaries. + @Test("APITokenManager sendable compliance") + internal func sendableCompliance() async { + let validToken = TestConstants.apiToken + let manager = APITokenManager(apiToken: validToken) + + async let task1 = manager.validateManager() + async let task2 = manager.authenticatorFromManager() + async let task3 = manager.checkHasCredentials() + + let results = await (task1, task2, task3) + #expect(results.0 == true) + #expect(results.1 != nil) + #expect(results.2 == true) + } + } +} diff --git a/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests.swift b/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests.swift deleted file mode 100644 index 1b181527..00000000 --- a/Tests/MistKitTests/Authentication/APIToken/APITokenManagerTests.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("API Token Manager") -/// Test suite for APITokenManager functionality -internal struct APITokenManagerTests { - // MARK: - Initialization Tests - - /// Tests APITokenManager initialization with valid API token - @Test("APITokenManager initialization with valid API token") - internal func initializationValidToken() { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - #expect(manager.token == validToken) - #expect(manager.isValidFormat == true) - } - - /// Tests APITokenManager initialization with invalid API token format - @Test("APITokenManager initialization with invalid API token format") - internal func initializationInvalidToken() { - let invalidToken = "invalid_token_format" - let manager = APITokenManager(apiToken: invalidToken) - - #expect(manager.token == invalidToken) - #expect(manager.isValidFormat == false) - } - - /// Tests APITokenManager initialization with empty token (should crash) - @Test("APITokenManager initialization with empty token") - internal func initializationEmptyToken() { - _ = "" - - // This should crash due to precondition - we can't easily test this with Swift Testing - // Instead, we'll test that a valid token works - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - #expect(manager.token == validToken) - } - - // MARK: - TokenManager Protocol Tests - - /// Tests hasCredentials property - @Test("hasCredentials property") - internal func hasCredentials() async { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - let hasCredentials = await manager.hasCredentials - #expect(hasCredentials == true) - } - - /// Tests validateCredentials with valid token - @Test("validateCredentials with valid token") - internal func validateCredentialsValidToken() async throws { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - let isValid = try await manager.validateCredentials() - #expect(isValid == true) - } - - /// Tests validateCredentials with invalid token - @Test("validateCredentials with invalid token") - internal func validateCredentialsInvalidToken() async throws { - let invalidToken = "invalid_token_format" - let manager = APITokenManager(apiToken: invalidToken) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(let reason): - if case .apiTokenInvalidFormat = reason { - // Expected case - } else { - Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") - } - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests validateCredentials with token that's too short - @Test("validateCredentials with token that's too short") - internal func validateCredentialsShortToken() async throws { - let shortToken = "abc123" - let manager = APITokenManager(apiToken: shortToken) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(let reason): - if case .apiTokenInvalidFormat = reason { - // Expected case - } else { - Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") - } - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests validateCredentials with token that's too long - @Test("validateCredentials with token that's too long") - internal func validateCredentialsLongToken() async throws { - let longToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd12345" - let manager = APITokenManager(apiToken: longToken) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(let reason): - if case .apiTokenInvalidFormat = reason { - // Expected case - } else { - Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") - } - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests validateCredentials with non-hex characters - @Test("validateCredentials with non-hex characters") - internal func validateCredentialsNonHexToken() async throws { - let nonHexToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd12gh" - let manager = APITokenManager(apiToken: nonHexToken) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(let reason): - if case .apiTokenInvalidFormat = reason { - // Expected case - } else { - Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") - } - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests getCurrentCredentials with valid token - @Test("getCurrentCredentials with valid token") - internal func getCurrentCredentialsValidToken() async throws { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .apiToken(let token) = credentials.method { - #expect(token == validToken) - } else { - Issue.record("Expected .apiToken method") - } - } - } - - /// Tests getCurrentCredentials with invalid token - @Test("getCurrentCredentials with invalid token") - internal func getCurrentCredentialsInvalidToken() async throws { - let invalidToken = "invalid_token_format" - let manager = APITokenManager(apiToken: invalidToken) - - do { - _ = try await manager.getCurrentCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(let reason): - if case .apiTokenInvalidFormat = reason { - // Expected case - } else { - Issue.record("Expected .apiTokenInvalidFormat, got: \(reason)") - } - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - // MARK: - Extension Methods Tests - - /// Tests isValidFormat property with valid token - @Test("isValidFormat property with valid token") - internal func isValidFormatValidToken() { - let validToken = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let manager = APITokenManager(apiToken: validToken) - - #expect(manager.isValidFormat == true) - } - - /// Tests isValidFormat property with invalid token - @Test("isValidFormat property with invalid token") - internal func isValidFormatInvalidToken() { - let invalidToken = "invalid_token_format" - let manager = APITokenManager(apiToken: invalidToken) - - #expect(manager.isValidFormat == false) - } -} diff --git a/Tests/MistKitTests/Authentication/AdaptiveTokenManager/AdaptiveTokenManager+TestHelpers.swift b/Tests/MistKitTests/Authentication/AdaptiveTokenManager/AdaptiveTokenManager+TestHelpers.swift new file mode 100644 index 00000000..8c1afd64 --- /dev/null +++ b/Tests/MistKitTests/Authentication/AdaptiveTokenManager/AdaptiveTokenManager+TestHelpers.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing + +@testable import MistKit + +extension AdaptiveTokenManager { + /// Test helper to validate credentials and return a boolean result + internal func validateManager() async -> Bool { + do { + return try await validateCredentials() + } catch { + return false + } + } + + /// Test helper to get the current authenticator or nil on failure. + internal func authenticatorFromManager() async -> (any Authenticator)? { + do { + return try await currentAuthenticator() + } catch { + return nil + } + } + + /// Test helper to check if credentials are available + internal func checkHasCredentials() async -> Bool { + await hasCredentials + } +} diff --git a/Tests/MistKitTests/AdaptiveTokenManager/IntegrationTests.swift b/Tests/MistKitTests/Authentication/AdaptiveTokenManager/IntegrationTests.swift similarity index 81% rename from Tests/MistKitTests/AdaptiveTokenManager/IntegrationTests.swift rename to Tests/MistKitTests/Authentication/AdaptiveTokenManager/IntegrationTests.swift index ee1e2455..6110528c 100644 --- a/Tests/MistKitTests/AdaptiveTokenManager/IntegrationTests.swift +++ b/Tests/MistKitTests/Authentication/AdaptiveTokenManager/IntegrationTests.swift @@ -8,13 +8,13 @@ internal enum AdaptiveTokenManagerTests {} extension AdaptiveTokenManagerTests { /// Integration tests for AdaptiveTokenManager - @Suite("Integration Tests") + @Suite("Integration") internal struct IntegrationTests { // MARK: - Test Data Setup private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - // private static let validWebAuthToken = "user123_web_auth_token_abcdef" + TestConstants.apiToken + // private static let validWebAuthToken = TestConstants.webAuthToken // MARK: - Basic Integration Tests @@ -66,23 +66,16 @@ extension AdaptiveTokenManagerTests { #expect(isValid == true) } - /// Tests AdaptiveTokenManager getCurrentCredentials - @Test("getCurrentCredentials with valid token") - internal func getCurrentCredentialsWithValidToken() async throws { + /// Tests AdaptiveTokenManager currentAuthenticator + @Test("currentAuthenticator with valid token") + internal func currentAuthenticatorWithValidToken() async throws { let tokenManager = AdaptiveTokenManager( apiToken: Self.validAPIToken ) - let credentials = try await tokenManager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .apiToken(let api) = credentials.method { - #expect(api == Self.validAPIToken) - } else { - Issue.record("Expected .apiToken method") - } - } + let authenticator = try await tokenManager.currentAuthenticator() + let api = try #require(authenticator as? APITokenAuthenticator) + #expect(api.token == Self.validAPIToken) } /// Tests AdaptiveTokenManager with empty API token @@ -107,7 +100,7 @@ extension AdaptiveTokenManagerTests { // Test concurrent access patterns async let task1 = tokenManager.validateManager() - async let task2 = tokenManager.getCredentialsFromManager() + async let task2 = tokenManager.authenticatorFromManager() async let task3 = tokenManager.checkHasCredentials() let results = await (task1, task2, task3) diff --git a/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Basic.swift b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Basic.swift new file mode 100644 index 00000000..6f9f5882 --- /dev/null +++ b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Basic.swift @@ -0,0 +1,110 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension ConcurrentTokenRefreshTests { + /// Test suite for basic concurrent token refresh functionality + @Suite("Basic") + internal struct Basic { + // MARK: - Basic Concurrent Token Refresh Tests + + /// Tests concurrent token refresh with multiple requests + @Test("Concurrent token refresh with multiple requests") + internal func concurrentTokenRefreshWithMultipleRequests() async throws { + let mockTokenManager = MockTokenManagerWithRefresh() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let request = ConcurrentTokenRefreshTests.makeRequest() + let next = ConcurrentTokenRefreshTests.successNextHandler() + let baseURL = CloudKitService.baseURL + + // Test concurrent access patterns + let results = await ConcurrentTokenRefreshTests.runConcurrent( + middleware: middleware, + request: request, + baseURL: baseURL, + next: next, + count: 5 + ) + + // Verify all requests succeeded + for result in results { + #expect(result == true) + } + + // Verify that refresh was called for each concurrent request + #expect(await mockTokenManager.refreshCallCount == 5) + } + + /// Tests concurrent token refresh with different token managers + @Test("Concurrent token refresh with different token managers") + internal func concurrentTokenRefreshWithDifferentTokenManagers() async throws { + let tokenManagers = [ + MockTokenManagerWithRefresh(), + MockTokenManagerWithRefresh(), + MockTokenManagerWithRefresh(), + ] + + let middlewares = tokenManagers.map { AuthenticationMiddleware(tokenManager: $0) } + + let request = ConcurrentTokenRefreshTests.makeRequest() + let next = ConcurrentTokenRefreshTests.successNextHandler() + let baseURL = CloudKitService.baseURL + + // Test concurrent access with different middlewares + let results = await executeConcurrentMiddlewareCallsWithDifferentMiddlewares( + middlewares: middlewares, + request: request, + baseURL: baseURL, + next: next + ) + + // Verify all requests succeeded + for result in results { + #expect(result == true) + } + + // Each token manager should have refreshed once + for tokenManager in tokenManagers { + #expect(await tokenManager.refreshCallCount == 1) + } + } + + /// Executes concurrent middleware calls with different middlewares + private func executeConcurrentMiddlewareCallsWithDifferentMiddlewares( + middlewares: [AuthenticationMiddleware], + request: HTTPRequest, + baseURL: URL, + next: + @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> ( + HTTPResponse, HTTPBody? + ) + ) async -> [Bool] { + let tasks = middlewares.map { middleware in + Task { + await middleware.interceptWithMiddleware( + request: request, + baseURL: baseURL, + operationID: TestConstants.operationID, + next: next + ) + } + } + + return await withTaskGroup(of: Bool.self) { group in + for task in tasks { + group.addTask { await task.value } + } + + var results: [Bool] = [] + for await result in group { + results.append(result) + } + return results + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Error.swift b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Error.swift new file mode 100644 index 00000000..db948395 --- /dev/null +++ b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Error.swift @@ -0,0 +1,68 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension ConcurrentTokenRefreshTests { + /// Test suite for concurrent token refresh error handling functionality + @Suite("Error") + internal struct Error { + // MARK: - Error Scenario Tests + + /// Tests concurrent token refresh with refresh failures + @Test("Concurrent token refresh with refresh failures") + internal func concurrentTokenRefreshWithRefreshFailures() async throws { + let mockTokenManager = MockTokenManagerWithRefreshFailure() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let request = ConcurrentTokenRefreshTests.makeRequest() + let next = ConcurrentTokenRefreshTests.successNextHandler() + let baseURL = CloudKitService.baseURL + + // Test concurrent access with refresh failures + let results = await ConcurrentTokenRefreshTests.runConcurrent( + middleware: middleware, + request: request, + baseURL: baseURL, + next: next, + count: 3 + ) + + // At least one should fail due to refresh failure + let hasFailure = results.contains(false) + #expect(hasFailure) + + // Verify that refresh was attempted + #expect(await mockTokenManager.refreshCallCount > 0) + } + + /// Tests concurrent token refresh with timeout scenarios + @Test("Concurrent token refresh with timeout scenarios") + internal func concurrentTokenRefreshWithTimeoutScenarios() async throws { + let mockTokenManager = MockTokenManagerWithRefreshTimeout() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let request = ConcurrentTokenRefreshTests.makeRequest() + let next = ConcurrentTokenRefreshTests.successNextHandler() + let baseURL = CloudKitService.baseURL + + // Test concurrent access with timeout scenarios + let results = await ConcurrentTokenRefreshTests.runConcurrent( + middleware: middleware, + request: request, + baseURL: baseURL, + next: next, + count: 3 + ) + + // Results may vary due to timeout, but at least one should complete + let hasSuccess = results.contains(true) + #expect(hasSuccess) + + // Verify that refresh was attempted + #expect(await mockTokenManager.refreshCallCount > 0) + } + } +} diff --git a/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Helpers.swift b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Helpers.swift new file mode 100644 index 00000000..b026177c --- /dev/null +++ b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Helpers.swift @@ -0,0 +1,61 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension ConcurrentTokenRefreshTests { + /// Creates a standard test request used across the concurrent suites. + internal static func makeRequest() -> HTTPRequest { + HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + } + + /// Returns a next-handler closure that always succeeds with `200 OK`. + internal static func successNextHandler() + -> @Sendable (HTTPRequest, HTTPBody?, URL) async throws + -> (HTTPResponse, HTTPBody?) + { + { _, _, _ in (HTTPResponse(status: .ok), nil) } + } + + /// Fans out `count` concurrent middleware calls and gathers their results. + internal static func runConcurrent( + middleware: AuthenticationMiddleware, + request: HTTPRequest, + baseURL: URL, + next: + @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> ( + HTTPResponse, HTTPBody? + ), + count: Int + ) async -> [Bool] { + let tasks = (1...count).map { _ in + Task { + await middleware.interceptWithMiddleware( + request: request, + baseURL: baseURL, + operationID: TestConstants.operationID, + next: next + ) + } + } + + return await withTaskGroup(of: Bool.self) { group in + for task in tasks { + group.addTask { await task.value } + } + + var results: [Bool] = [] + for await result in group { + results.append(result) + } + return results + } + } +} diff --git a/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Performance.swift b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Performance.swift new file mode 100644 index 00000000..85644222 --- /dev/null +++ b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests+Performance.swift @@ -0,0 +1,42 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension ConcurrentTokenRefreshTests { + /// Test suite for concurrent token refresh performance functionality + @Suite("Performance") + internal struct Performance { + // MARK: - Performance Scenario Tests + + /// Tests concurrent token refresh with rate limiting + @Test("Concurrent token refresh with rate limiting") + internal func concurrentTokenRefreshWithRateLimiting() async throws { + let mockTokenManager = MockTokenManagerWithRateLimiting() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let request = ConcurrentTokenRefreshTests.makeRequest() + let next = ConcurrentTokenRefreshTests.successNextHandler() + let baseURL = CloudKitService.baseURL + + // Test concurrent access with rate limiting + let results = await ConcurrentTokenRefreshTests.runConcurrent( + middleware: middleware, + request: request, + baseURL: baseURL, + next: next, + count: 3 + ) + + // All should succeed eventually due to rate limiting handling + for result in results { + #expect(result == true) + } + + // Verify that refresh was called multiple times due to rate limiting + #expect(await mockTokenManager.refreshCallCount >= 3) + } + } +} diff --git a/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests.swift b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests.swift new file mode 100644 index 00000000..bca44e4c --- /dev/null +++ b/Tests/MistKitTests/Authentication/ConcurrentTokenRefresh/ConcurrentTokenRefreshTests.swift @@ -0,0 +1,4 @@ +import Testing + +@Suite("Concurrent Token Refresh") +internal enum ConcurrentTokenRefreshTests {} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift new file mode 100644 index 00000000..2560f94f --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift @@ -0,0 +1,62 @@ +// +// CredentialsTokenManagerTests+PrivateKeyLoad.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CredentialsTokenManagerTests { + @Suite("Private-Key Load Failure") + internal struct PrivateKeyLoad { + @Test(".public + S2S with unreadable PEM file → throws invalidPrivateKey") + internal func publicWithUnreadablePEMFileThrowsInvalidPrivateKey() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let missingPath = "/nonexistent/path/to/private-key-\(UUID().uuidString).pem" + let credentials = try Credentials( + serverToServer: ServerToServerCredentials( + keyID: "test-key-id-12345678", + privateKey: .file(path: missingPath) + ) + ) + do { + _ = try credentials.makeTokenManager(for: .public(.requires(.serverToServer))) + Issue.record("expected makeTokenManager to throw .invalidPrivateKey") + } catch let error as CloudKitError { + guard case .invalidPrivateKey(let path, _) = error else { + Issue.record("expected .invalidPrivateKey, got \(error)") + return + } + #expect(path == missingPath) + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateShared.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateShared.swift new file mode 100644 index 00000000..061223fc --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateShared.swift @@ -0,0 +1,114 @@ +// +// CredentialsTokenManagerTests+PrivateShared.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CredentialsTokenManagerTests { + @Suite("Private / Shared Database") + internal struct PrivateShared { + @Test(".private + apiAuth.webAuthToken → WebAuthTokenManager") + internal func privatePicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager(for: .private) + #expect(manager is WebAuthTokenManager) + } + + @Test(".shared + apiAuth.webAuthToken → WebAuthTokenManager") + internal func sharedPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager(for: .shared) + #expect(manager is WebAuthTokenManager) + } + + @Test(".private + serverToServer only → throws missingCredentials") + internal func privateRejectsServerToServerOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .private) + } + } + + @Test(".shared + serverToServer only → throws missingCredentials") + internal func sharedRejectsServerToServerOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .shared) + } + } + + @Test(".private + apiAuth without webAuthToken → throws missingCredentials") + internal func privateRejectsAPITokenOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .private) + } + } + + @Test(".shared + apiAuth without webAuthToken → throws missingCredentials") + internal func sharedRejectsAPITokenOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .shared) + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift new file mode 100644 index 00000000..42640057 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift @@ -0,0 +1,224 @@ +// +// CredentialsTokenManagerTests+PublicDatabase.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CredentialsTokenManagerTests { + @Suite("Public Database") + internal struct PublicDatabase { + // MARK: - prefers(.serverToServer) + + @Test(".public(.prefers(.serverToServer)) + S2S only → S2S") + internal func prefersS2SOnlyS2SPicksS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("ServerToServerAuthManager is not available on this operating system.") + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.serverToServer)) + both creds → S2S") + internal func prefersS2SBothCredsPicksS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.serverToServer)) + web-auth only → falls back to web-auth") + internal func prefersS2SOnlyWebAuthFallsBackToWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.prefers(.serverToServer)) + API token only → APITokenManager") + internal func prefersS2SAPITokenOnlyFallsBackToAPIToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is APITokenManager) + } + + // MARK: - prefers(.webAuth) + + @Test(".public(.prefers(.webAuth)) + both creds → web-auth") + internal func prefersWebAuthBothCredsPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.prefers(.webAuth)) + S2S only → falls back to S2S") + internal func prefersWebAuthOnlyS2SFallsBackToS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.webAuth)) + API token only → APITokenManager") + internal func prefersWebAuthAPITokenOnlyFallsBackToAPIToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) + #expect(manager is APITokenManager) + } + + // MARK: - requires(.serverToServer) + + @Test(".public(.requires(.serverToServer)) + both creds → S2S") + internal func requiresS2SBothCredsPicksS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.serverToServer)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.requires(.serverToServer)) without S2S → throws preferenceRequired") + internal func requiresS2SWithoutS2SThrowsPreferenceRequired() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + #expect { + _ = try credentials.makeTokenManager( + for: .public(.requires(.serverToServer)) + ) + } throws: { error in + guard + let cloudKitError = error as? CloudKitError, + case .missingCredentials(_, let availability, _) = cloudKitError + else { return false } + return availability == .preferenceRequired + } + } + + // MARK: - requires(.webAuth) + + @Test(".public(.requires(.webAuth)) + both creds → web-auth") + internal func requiresWebAuthBothCredsPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.requires(.webAuth)) without web-auth → throws preferenceRequired") + internal func requiresWebAuthWithoutWebAuthThrowsPreferenceRequired() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + #expect { + _ = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + } throws: { error in + guard + let cloudKitError = error as? CloudKitError, + case .missingCredentials(_, let availability, _) = cloudKitError + else { return false } + return availability == .preferenceRequired + } + } + + // Note: The "no creds at all" path in the dispatcher's resolution table + // (".prefers + neither mode configured → notConfigured") is unreachable + // because `Credentials.init` requires at least one of `serverToServer` + // or `apiAuth`; constructing an empty `Credentials` isn't permitted. + } +} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift new file mode 100644 index 00000000..4774b0bf --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift @@ -0,0 +1,88 @@ +// +// CredentialsTokenManagerTests+UserContext.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CredentialsTokenManagerTests { + /// Coverage for the "user-context" routes (`users/caller`, + /// `users/lookup/*`, `users/discover`). With the per-call + /// `PublicAuthPreference` rewrite these no longer take a separate + /// `requiresUserContext` flag — they pass `.public(.requires(.webAuth))` + /// directly to the dispatcher. + @Suite("User-Context Branch") + internal struct UserContext { + @Test(".public(.requires(.webAuth)) + both creds → web-auth (S2S ignored)") + internal func requiresWebAuthOnPublicIgnoresS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.requires(.webAuth)) + S2S only → throws preferenceRequired") + internal func requiresWebAuthWithoutWebAuthThrows() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + } + } + + @Test(".public(.requires(.webAuth)) + API token only → throws preferenceRequired") + internal func requiresWebAuthWithAPITokenOnlyThrows() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift new file mode 100644 index 00000000..b13c137f --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift @@ -0,0 +1,70 @@ +// +// CredentialsTokenManagerTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Crypto +import Foundation +import Testing + +@testable import MistKit + +/// Direct unit coverage for `Credentials.makeTokenManager(for:requiresUserContext:)`. +/// +/// Each `CloudKitService` operation calls this resolver to pick a token +/// manager based on the target database and whether the route requires +/// user-context auth. The sub-suites below cover every cell of the routing +/// matrix: the four combinations on `.public` plus the two error cases on +/// `.private`/`.shared`, the user-context branch, and PEM-load failure. +@Suite("Credentials.makeTokenManager", .enabled(if: Platform.isCryptoAvailable)) +internal enum CredentialsTokenManagerTests { + internal static func makeServerToServerCredentials() -> ServerToServerCredentials { + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + let pem = P256.Signing.PrivateKey().pemRepresentation + return ServerToServerCredentials( + keyID: "test-key-id-12345678", + privateKey: .raw(pem) + ) + } else { + Issue.record( + "ServerToServerCredentials requires macOS 11.0+ / iOS 14.0+ / tvOS 14.0+ / watchOS 7.0+" + ) + return ServerToServerCredentials(keyID: "unavailable", privateKey: .raw("")) + } + } + + internal static func makeAPICredentialsWithWebAuth() -> APICredentials { + APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + } + + internal static func makeAPICredentialsTokenOnly() -> APICredentials { + APICredentials(apiToken: TestConstants.apiToken) + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorage+TestHelpers.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorage+TestHelpers.swift new file mode 100644 index 00000000..3ea59378 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorage+TestHelpers.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorage { + /// Test helper to store an authenticator and return a boolean result. + internal func storeAuthenticator(_ authenticator: any Authenticator) async -> Bool { + do { + try await store(authenticator, identifier: nil) + return true + } catch { + return false + } + } + + /// Test helper to get an authenticator by identifier. + internal func getAuthenticator(identifier: String? = nil) async -> (any Authenticator)? { + try? await retrieve(identifier: identifier) + } + + /// Test helper to store and retrieve an authenticator. + internal func storeAndRetrieve(_ authenticator: any Authenticator) async -> Bool { + do { + try await store(authenticator, identifier: nil) + let retrieved = try await retrieve(identifier: nil) + return retrieved != nil + } catch { + return false + } + } + + /// Test helper to remove a token by identifier. + internal func removeToken(identifier: String) async -> Bool { + do { + try await remove(identifier: identifier) + return true + } catch { + return false + } + } + + /// Test helper to get a token by identifier. + internal func getToken(identifier: String) async -> (any Authenticator)? { + try? await retrieve(identifier: identifier) + } + + /// Test helper to store an API token authenticator under a key. + internal func storeToken(key: String, token: String) async throws { + let authenticator = try APITokenAuthenticator(token: token) + try await store(authenticator, identifier: key) + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentRemovalTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentRemovalTests.swift new file mode 100644 index 00000000..60a43211 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentRemovalTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorageTests { + /// Concurrent removal tests for InMemoryTokenStorage + @Suite("Concurrent Removal") + internal struct ConcurrentRemovalTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + + // MARK: - Concurrent Removal Tests + + /// Tests concurrent token removal + @Test("Concurrent token removal") + internal func concurrentTokenRemoval() async throws { + let storage = InMemoryTokenStorage() + + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth3 = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(auth1, identifier: "concurrent1") + try await storage.store(auth2, identifier: "concurrent2") + try await storage.store(auth3, identifier: "concurrent3") + + async let task1 = storage.removeToken(identifier: "concurrent1") + async let task2 = storage.removeToken(identifier: "concurrent2") + async let task3 = storage.removeToken(identifier: "concurrent3") + + let results = await (task1, task2, task3) + #expect(results.0 == true) + #expect(results.1 == true) + #expect(results.2 == true) + + let identifiers = try await storage.listIdentifiers() + #expect(!identifiers.contains("concurrent1")) + #expect(!identifiers.contains("concurrent2")) + #expect(!identifiers.contains("concurrent3")) + } + + /// Tests concurrent removal and retrieval + @Test("Concurrent removal and retrieval") + internal func concurrentRemovalAndRetrieval() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: "concurrent-test") + + async let task1 = storage.removeToken(identifier: "concurrent-test") + async let task2 = storage.getToken(identifier: "concurrent-test") + async let task3 = storage.removeToken(identifier: "concurrent-test") + + let results = await (task1, task2, task3) + _ = results.1 + #expect(results.0 == true || results.2 == true) + + let retrieved = try await storage.retrieve(identifier: "concurrent-test") + #expect(retrieved == nil) + } + + // MARK: - Storage State Tests + + /// Tests storage state after removal + @Test("Storage state after removal") + internal func storageStateAfterRemoval() async throws { + let storage = InMemoryTokenStorage() + + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(auth1, identifier: "state1") + try await storage.store(auth2, identifier: "state2") + + let isEmptyBefore = await storage.isEmpty + #expect(isEmptyBefore == false) + + let countBefore = await storage.count + #expect(countBefore == 2) + + try await storage.remove(identifier: "state1") + + let isEmptyAfter = await storage.isEmpty + #expect(isEmptyAfter == false) + + let countAfter = await storage.count + #expect(countAfter == 1) + + try await storage.remove(identifier: "state2") + + let isEmptyFinal = await storage.isEmpty + #expect(isEmptyFinal == true) + + let countFinal = await storage.count + #expect(countFinal == 0) + } + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentTests.swift new file mode 100644 index 00000000..9a3d5eb5 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing + +@testable import MistKit + +internal enum InMemoryTokenStorageTests {} + +extension InMemoryTokenStorageTests { + /// Concurrent access tests for InMemoryTokenStorage + @Suite("Concurrent") + internal struct ConcurrentTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + + // MARK: - Concurrent Access Tests + + /// Tests concurrent storage operations + @Test("Concurrent storage operations") + internal func concurrentStorageOperations() async throws { + let storage = InMemoryTokenStorage() + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth3 = try APITokenAuthenticator(token: Self.testAPIToken) + + async let task1 = storage.storeAuthenticator(auth1) + async let task2 = storage.storeAuthenticator(auth2) + async let task3 = storage.storeAuthenticator(auth3) + + let results = await (task1, task2, task3) + #expect(results.0 == true) + #expect(results.1 == true) + #expect(results.2 == true) + + let retrieved = try await storage.retrieve(identifier: nil) + #expect(retrieved != nil) + } + + /// Tests concurrent retrieval operations + @Test("Concurrent retrieval operations") + internal func concurrentRetrievalOperations() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + + async let task1 = storage.getAuthenticator() + async let task2 = storage.getAuthenticator() + async let task3 = storage.getAuthenticator() + + let results = await (task1, task2, task3) + let api1 = try #require(results.0 as? APITokenAuthenticator) + let api2 = try #require(results.1 as? APITokenAuthenticator) + let api3 = try #require(results.2 as? APITokenAuthenticator) + #expect(api1.token == Self.testAPIToken) + #expect(api2.token == api1.token) + #expect(api3.token == api1.token) + } + + // MARK: - Sendable Compliance Tests + + /// Tests that InMemoryTokenStorage can be used across async boundaries + @Test("InMemoryTokenStorage sendable compliance") + internal func sendableCompliance() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + async let task1 = storage.storeAndRetrieve(authenticator) + async let task2 = storage.storeAndRetrieve(authenticator) + async let task3 = storage.storeAndRetrieve(authenticator) + + let results = await (task1, task2, task3) + #expect(results.0 == true) + #expect(results.1 == true) + #expect(results.2 == true) + } + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ExpirationTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ExpirationTests.swift new file mode 100644 index 00000000..6b49aa98 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ExpirationTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorageTests { + /// Expiration handling tests for InMemoryTokenStorage + @Suite("Expiration") + internal struct ExpirationTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + + // MARK: - Token Expiration Tests + + /// Tests storing token with expiration time + @Test("Store token with expiration time") + internal func storeTokenWithExpirationTime() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let expirationTime = Date().addingTimeInterval(3_600) + + try await storage.store( + authenticator, + identifier: "test", + expirationTime: expirationTime + ) + + let retrieved = try await storage.retrieve(identifier: "test") + let api = try #require(retrieved as? APITokenAuthenticator) + #expect(api.token == Self.testAPIToken) + } + + /// Tests retrieving expired token + @Test("Retrieve expired token") + internal func retrieveExpiredToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let expirationTime = Date().addingTimeInterval(-3_600) + + try await storage.store( + authenticator, + identifier: "expired", + expirationTime: expirationTime + ) + + let retrieved = try await storage.retrieve(identifier: "expired") + #expect(retrieved == nil) + } + + /// Tests retrieving non-expired token + @Test("Retrieve non-expired token") + internal func retrieveNonExpiredToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let expirationTime = Date().addingTimeInterval(3_600) + + try await storage.store( + authenticator, + identifier: "valid", + expirationTime: expirationTime + ) + + let retrieved = try await storage.retrieve(identifier: "valid") + #expect(retrieved != nil) + } + + /// Tests storing token without expiration time + @Test("Store token without expiration time") + internal func storeTokenWithoutExpirationTime() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: "no-expiry", expirationTime: nil) + + let retrieved = try await storage.retrieve(identifier: "no-expiry") + #expect(retrieved != nil) + } + + /// Tests token expiration cleanup + @Test("Token expiration cleanup") + internal func tokenExpirationCleanup() async throws { + let storage = InMemoryTokenStorage() + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth3 = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store( + auth1, + identifier: "expired1", + expirationTime: Date().addingTimeInterval(-3_600) + ) + try await storage.store( + auth2, + identifier: "expired2", + expirationTime: Date().addingTimeInterval(-1_800) + ) + try await storage.store( + auth3, + identifier: "valid", + expirationTime: Date().addingTimeInterval(3_600) + ) + + let identifiersBefore = try await storage.listIdentifiers() + #expect(identifiersBefore.count == 3) + + await storage.cleanupExpiredTokens() + + let identifiersAfter = try await storage.listIdentifiers() + #expect(identifiersAfter.count == 1) + #expect(identifiersAfter.contains("valid")) + + let retrievedExpired1 = try await storage.retrieve(identifier: "expired1") + let retrievedExpired2 = try await storage.retrieve(identifier: "expired2") + let retrievedValid = try await storage.retrieve(identifier: "valid") + + #expect(retrievedExpired1 == nil) + #expect(retrievedExpired2 == nil) + #expect(retrievedValid != nil) + } + + /// Tests automatic expiration during retrieval + @Test("Automatic expiration during retrieval") + internal func automaticExpirationDuringRetrieval() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let expirationTime = Date().addingTimeInterval(-1) + + try await storage.store( + authenticator, + identifier: "auto-expired", + expirationTime: expirationTime + ) + + let retrieved = try await storage.retrieve(identifier: "auto-expired") + #expect(retrieved == nil) + + let identifiers = try await storage.listIdentifiers() + #expect(!identifiers.contains("auto-expired")) + } + + /// Tests storing multiple tokens with different expiration times + @Test("Store multiple tokens with different expiration times") + internal func storeMultipleTokensWithDifferentExpirationTimes() async throws { + let storage = InMemoryTokenStorage() + let now = Date() + + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth3 = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store( + auth1, + identifier: "short", + expirationTime: now.addingTimeInterval(60) + ) + try await storage.store( + auth2, + identifier: "medium", + expirationTime: now.addingTimeInterval(3_600) + ) + try await storage.store( + auth3, + identifier: "long", + expirationTime: now.addingTimeInterval(86_400) + ) + + let retrieved1 = try await storage.retrieve(identifier: "short") + let retrieved2 = try await storage.retrieve(identifier: "medium") + let retrieved3 = try await storage.retrieve(identifier: "long") + + #expect(retrieved1 != nil) + #expect(retrieved2 != nil) + #expect(retrieved3 != nil) + } + + /// Tests expiration time edge cases + @Test("Expiration time edge cases") + internal func expirationTimeEdgeCases() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + let exactExpiration = Date() + try await storage.store( + authenticator, + identifier: "exact", + expirationTime: exactExpiration + ) + + let retrieved = try await storage.retrieve(identifier: "exact") + #expect(retrieved == nil) + } + + /// Tests concurrent access with expiration + @Test("Concurrent access with expiration") + internal func concurrentAccessWithExpiration() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let expirationTime = Date().addingTimeInterval(3_600) + + try await storage.store( + authenticator, + identifier: "concurrent", + expirationTime: expirationTime + ) + + async let task1 = storage.getAuthenticator(identifier: "concurrent") + async let task2 = storage.getAuthenticator(identifier: "concurrent") + async let task3 = storage.getAuthenticator(identifier: "concurrent") + + let results = await (task1, task2, task3) + #expect(results.0 != nil) + #expect(results.1 != nil) + #expect(results.2 != nil) + } + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+InitializationTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+InitializationTests.swift new file mode 100644 index 00000000..56560218 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+InitializationTests.swift @@ -0,0 +1,81 @@ +import Crypto +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorageTests { + /// Test suite for InMemoryTokenStorage initialization and basic storage functionality + @Suite("In-Memory Token Storage Initialization") + internal struct InitializationTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + private static let testWebAuthToken = TestConstants.webAuthToken + + // MARK: - Initialization Tests + + /// Tests InMemoryTokenStorage initialization + @Test("InMemoryTokenStorage initialization") + internal func initialization() { + let storage = InMemoryTokenStorage() + // Storage should be created successfully + _ = storage + } + + // MARK: - Token Storage Tests + + /// Tests storing API token + @Test("Store API token") + internal func storeAPIToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + + let retrieved = try await storage.retrieve(identifier: nil) + let api = try #require(retrieved as? APITokenAuthenticator) + #expect(api.token == Self.testAPIToken) + } + + /// Tests storing web auth token + @Test("Store web auth token") + internal func storeWebAuthToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try WebAuthTokenAuthenticator( + apiToken: Self.testAPIToken, + webAuthToken: Self.testWebAuthToken + ) + + try await storage.store(authenticator, identifier: nil) + + let retrieved = try await storage.retrieve(identifier: nil) + let web = try #require(retrieved as? WebAuthTokenAuthenticator) + #expect(web.apiToken == Self.testAPIToken) + #expect(web.webAuthToken == Self.testWebAuthToken) + } + + /// Tests storing server-to-server credentials + @Test( + "Store server-to-server credentials", + .enabled(if: Platform.isCryptoAvailable) + ) + internal func storeServerToServerCredentials() async throws { + let storage = InMemoryTokenStorage() + let keyID = "test-key-id-12345678" + let privateKey = P256.Signing.PrivateKey() + let authenticator = try ServerToServerAuthenticator( + keyID: keyID, + privateKey: privateKey + ) + + try await storage.store(authenticator, identifier: nil) + + let retrieved = try await storage.retrieve(identifier: nil) + let s2s = try #require(retrieved as? ServerToServerAuthenticator) + #expect(s2s.keyID == keyID) + #expect(s2s.privateKey.rawRepresentation == privateKey.rawRepresentation) + } + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+RemovalTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+RemovalTests.swift new file mode 100644 index 00000000..dc5d585c --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+RemovalTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorageTests { + /// Token removal tests for InMemoryTokenStorage + @Suite("Removal") + internal struct RemovalTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + private static let testWebAuthToken = TestConstants.webAuthToken + + // MARK: - Basic Removal Tests + + /// Tests removing stored token by identifier + @Test("Remove stored token by identifier") + internal func removeStoredTokenByIdentifier() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: "test-token") + + let retrievedBefore = try await storage.retrieve(identifier: "test-token") + #expect(retrievedBefore != nil) + + try await storage.remove(identifier: "test-token") + + let retrievedAfter = try await storage.retrieve(identifier: "test-token") + #expect(retrievedAfter == nil) + } + + /// Tests removing non-existent token + @Test("Remove non-existent token") + internal func removeNonExistentToken() async throws { + let storage = InMemoryTokenStorage() + + try await storage.remove(identifier: "non-existent") + + let retrieved = try await storage.retrieve(identifier: "non-existent") + #expect(retrieved == nil) + } + + /// Tests removing token with nil identifier + @Test("Remove token with nil identifier") + internal func removeTokenWithNilIdentifier() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + + let retrievedBefore = try await storage.retrieve(identifier: nil) + #expect(retrievedBefore != nil) + + try await storage.remove(identifier: nil) + + let retrievedAfter = try await storage.retrieve(identifier: nil) + #expect(retrievedAfter == nil) + } + + // MARK: - Multiple Token Removal Tests + + /// Tests removing specific token from multiple stored tokens + @Test("Remove specific token from multiple stored tokens") + internal func removeSpecificTokenFromMultipleStoredTokens() async throws { + let storage = InMemoryTokenStorage() + + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try WebAuthTokenAuthenticator( + apiToken: Self.testAPIToken, + webAuthToken: Self.testWebAuthToken + ) + let auth3 = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(auth1, identifier: "api1") + try await storage.store(auth2, identifier: "web") + try await storage.store(auth3, identifier: "api3") + + let identifiersBefore = try await storage.listIdentifiers() + #expect(identifiersBefore.count == 3) + + try await storage.remove(identifier: "web") + + let identifiersAfter = try await storage.listIdentifiers() + #expect(identifiersAfter.count == 2) + #expect(identifiersAfter.contains("api1")) + #expect(identifiersAfter.contains("api3")) + #expect(!identifiersAfter.contains("web")) + + let retrievedWeb = try await storage.retrieve(identifier: "web") + #expect(retrievedWeb == nil) + + let retrievedApi1 = try await storage.retrieve(identifier: "api1") + let retrievedApi3 = try await storage.retrieve(identifier: "api3") + #expect(retrievedApi1 != nil) + #expect(retrievedApi3 != nil) + } + + /// Tests removing all tokens by clearing storage + @Test("Remove all tokens by clearing storage") + internal func removeAllTokensByClearingStorage() async throws { + let storage = InMemoryTokenStorage() + + let auth1 = try APITokenAuthenticator(token: Self.testAPIToken) + let auth2 = try WebAuthTokenAuthenticator( + apiToken: Self.testAPIToken, + webAuthToken: Self.testWebAuthToken + ) + let auth3 = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(auth1, identifier: "api1") + try await storage.store(auth2, identifier: "web") + try await storage.store(auth3, identifier: "api3") + + let identifiersBefore = try await storage.listIdentifiers() + #expect(identifiersBefore.count == 3) + + await storage.clear() + + let identifiersAfter = try await storage.listIdentifiers() + #expect(identifiersAfter.isEmpty) + + let retrievedApi1 = try await storage.retrieve(identifier: "api1") + let retrievedWeb = try await storage.retrieve(identifier: "web") + let retrievedApi3 = try await storage.retrieve(identifier: "api3") + #expect(retrievedApi1 == nil) + #expect(retrievedWeb == nil) + #expect(retrievedApi3 == nil) + } + + // MARK: - Edge Case Removal Tests + + /// Tests removing token with empty string identifier + @Test("Remove token with empty string identifier") + internal func removeTokenWithEmptyStringIdentifier() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: "") + + let retrievedBefore = try await storage.retrieve(identifier: "") + #expect(retrievedBefore != nil) + + try await storage.remove(identifier: "") + + let retrievedAfter = try await storage.retrieve(identifier: "") + #expect(retrievedAfter == nil) + } + + /// Tests removing token with special characters in identifier + @Test("Remove token with special characters in identifier") + internal func removeTokenWithSpecialCharactersInIdentifier() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let specialIdentifier = "test@#$%^&*()_+-={}[]|\\:;\"'<>?,./" + + try await storage.store(authenticator, identifier: specialIdentifier) + + let retrievedBefore = try await storage.retrieve(identifier: specialIdentifier) + #expect(retrievedBefore != nil) + + try await storage.remove(identifier: specialIdentifier) + + let retrievedAfter = try await storage.retrieve(identifier: specialIdentifier) + #expect(retrievedAfter == nil) + } + + /// Tests removing expired token + @Test("Remove expired token") + internal func removeExpiredToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + let expirationTime = Date().addingTimeInterval(-3_600) + + try await storage.store( + authenticator, + identifier: "expired", + expirationTime: expirationTime + ) + + let retrievedBefore = try await storage.retrieve(identifier: "expired") + #expect(retrievedBefore == nil) + + try await storage.remove(identifier: "expired") + + let retrievedAfter = try await storage.retrieve(identifier: "expired") + #expect(retrievedAfter == nil) + } + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ReplacementTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ReplacementTests.swift new file mode 100644 index 00000000..5469b1f9 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+ReplacementTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorageTests { + /// Test suite for InMemoryTokenStorage token replacement functionality + @Suite("In-Memory Token Storage Replacement") + internal struct ReplacementTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + private static let testWebAuthToken = TestConstants.webAuthToken + + // MARK: - Token Replacement Tests + + /// Tests replacing stored token with new token + @Test("Replace stored token with new token") + internal func replaceStoredTokenWithNewToken() async throws { + let storage = InMemoryTokenStorage() + let original = try APITokenAuthenticator(token: Self.testAPIToken) + let replacement = try WebAuthTokenAuthenticator( + apiToken: Self.testAPIToken, + webAuthToken: Self.testWebAuthToken + ) + + try await storage.store(original, identifier: nil) + + let retrievedBefore = try await storage.retrieve(identifier: nil) + #expect(retrievedBefore is APITokenAuthenticator) + + try await storage.store(replacement, identifier: nil) + + let retrievedAfter = try await storage.retrieve(identifier: nil) + let web = try #require(retrievedAfter as? WebAuthTokenAuthenticator) + #expect(web.apiToken == replacement.apiToken) + #expect(web.webAuthToken == replacement.webAuthToken) + } + + /// Tests replacing stored token with same token + @Test("Replace stored token with same token") + internal func replaceStoredTokenWithSameToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + + let retrievedBefore = try await storage.retrieve(identifier: nil) + #expect(retrievedBefore != nil) + + try await storage.store(authenticator, identifier: nil) + + let retrievedAfter = try await storage.retrieve(identifier: nil) + let api = try #require(retrievedAfter as? APITokenAuthenticator) + #expect(api.token == authenticator.token) + } + } +} diff --git a/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+RetrievalTests.swift b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+RetrievalTests.swift new file mode 100644 index 00000000..efa21884 --- /dev/null +++ b/Tests/MistKitTests/Authentication/InMemoryTokenStorage/InMemoryTokenStorageTests+RetrievalTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +@testable import MistKit + +extension InMemoryTokenStorageTests { + /// Test suite for InMemoryTokenStorage token retrieval and removal functionality + @Suite("In-Memory Token Storage Retrieval") + internal struct RetrievalTests { + // MARK: - Test Data Setup + + private static let testAPIToken = + TestConstants.apiToken + + // MARK: - Token Retrieval Tests + + /// Tests retrieving stored token + @Test("Retrieve stored token") + internal func retrieveStoredToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + + let retrieved = try await storage.retrieve(identifier: nil) + let api = try #require(retrieved as? APITokenAuthenticator) + #expect(api.token == authenticator.token) + } + + /// Tests retrieving non-existent token + @Test("Retrieve non-existent token") + internal func retrieveNonExistentToken() async throws { + let storage = InMemoryTokenStorage() + + let retrieved = try await storage.retrieve(identifier: nil) + #expect(retrieved == nil) + } + + /// Tests retrieving token after clearing storage + @Test("Retrieve token after clearing storage") + internal func retrieveTokenAfterClearingStorage() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + await storage.clear() + + let retrieved = try await storage.retrieve(identifier: nil) + #expect(retrieved == nil) + } + + // MARK: - Token Removal Tests + + /// Tests removing stored token + @Test("Remove stored token") + internal func removeStoredToken() async throws { + let storage = InMemoryTokenStorage() + let authenticator = try APITokenAuthenticator(token: Self.testAPIToken) + + try await storage.store(authenticator, identifier: nil) + + let retrievedBefore = try await storage.retrieve(identifier: nil) + #expect(retrievedBefore != nil) + + try await storage.remove(identifier: nil) + + let retrievedAfter = try await storage.retrieve(identifier: nil) + #expect(retrievedAfter == nil) + } + + /// Tests removing non-existent token + @Test("Remove non-existent token") + internal func removeNonExistentToken() async throws { + let storage = InMemoryTokenStorage() + + // Should not throw or crash + try await storage.remove(identifier: nil) + + let retrieved = try await storage.retrieve(identifier: nil) + #expect(retrieved == nil) + } + } +} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/AuthenticationMiddleware+TestHelpers.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddleware+TestHelpers.swift similarity index 100% rename from Tests/MistKitTests/AuthenticationMiddleware/AuthenticationMiddleware+TestHelpers.swift rename to Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddleware+TestHelpers.swift diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+APIToken.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+APIToken.swift new file mode 100644 index 00000000..ecf727d9 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+APIToken.swift @@ -0,0 +1,98 @@ +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + /// API Token authentication tests + @Suite("API Token", .enabled(if: Platform.isCryptoAvailable)) + internal struct APIToken { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let testOperationID = TestConstants.operationID + + // MARK: - API Token Authentication Tests + + /// Tests intercept with API token authentication + @Test("Intercept request with API token authentication") + internal func interceptWithAPITokenAuthentication() async throws { + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + var interceptedRequest: HTTPRequest? + var interceptedBaseURL: URL? + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, _, baseURL in + interceptedRequest = request + interceptedBaseURL = baseURL + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + #expect(interceptedBaseURL == CloudKitService.baseURL) + + if let interceptedRequest = interceptedRequest { + #expect(interceptedRequest.path?.contains("ckAPIToken=\(Self.validAPIToken)") == true) + } + } + + /// Tests intercept with API token authentication and existing query parameters + @Test("Intercept request with API token and existing query parameters") + internal func interceptWithAPITokenAuthenticationAndExistingQuery() async throws { + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query?existingParam=value" + ) + + var interceptedRequest: HTTPRequest? + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, _, _ in + interceptedRequest = request + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + + if let interceptedRequest = interceptedRequest { + let path = interceptedRequest.path ?? "" + #expect(path.contains("existingParam=value")) + #expect(path.contains("ckAPIToken=\(Self.validAPIToken)")) + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+Initialization.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+Initialization.swift new file mode 100644 index 00000000..5d2cb2fc --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+Initialization.swift @@ -0,0 +1,91 @@ +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + /// Basic functionality tests for AuthenticationMiddleware + @Suite("Initialization") + internal struct Initialization { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + private static let testOperationID = TestConstants.operationID + + // MARK: - Initialization Tests + + /// Tests AuthenticationMiddleware initialization with APITokenManager + @Test("Authentication Middleware initialization with API token manager") + internal func initializationWithAPITokenManager() { + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + // Middleware should be initialized + // Note: tokenManager is not optional, so we just verify it exists + _ = middleware.tokenManager + } + + /// Tests AuthenticationMiddleware initialization with WebAuthTokenManager + @Test("Authentication Middleware initialization with web auth token manager") + internal func initializationWithWebAuthTokenManager() { + let tokenManager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + // Middleware should be initialized + // Note: tokenManager is not optional, so we just verify it exists + _ = middleware.tokenManager + } + + // MARK: - Sendable Compliance Tests + + /// Tests that AuthenticationMiddleware can be used across async boundaries + @Test("Authentication Middleware sendable compliance") + internal func sendableCompliance() async throws { + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + // Test concurrent access patterns with separate closures + async let task1 = middleware.interceptWithMiddleware( + request: originalRequest, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID + ) { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + async let task2 = middleware.interceptWithMiddleware( + request: originalRequest, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID + ) { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + async let task3 = middleware.interceptWithMiddleware( + request: originalRequest, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID + ) { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + + let results = await (task1, task2, task3) + #expect(results.0 == true) + #expect(results.1 == true) + #expect(results.2 == true) + } + } +} diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+ServerToServer.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+ServerToServer.swift new file mode 100644 index 00000000..cf85a7bc --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+ServerToServer.swift @@ -0,0 +1,177 @@ +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + /// Server-to-server authentication tests for AuthenticationMiddleware + @Suite("Server-to-Server", .enabled(if: Platform.isCryptoAvailable)) + internal struct ServerToServer { + // MARK: - Test Data Setup + + private static let testOperationID = TestConstants.operationID + + // MARK: - Server-to-Server Authentication Tests + + /// Tests intercept with server-to-server authentication + @Test( + "Intercept request with server-to-server authentication", + .enabled(if: Platform.isCryptoAvailable)) + internal func interceptWithServerToServerAuthentication() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("ServerToServerAuthManager is not available on this operating system.") + return + } + let keyID = "test-key-id-12345678" + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + privateKeyCallback: P256.Signing.PrivateKey() + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + var interceptedRequest: HTTPRequest? + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, _, _ in + interceptedRequest = request + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + + if let interceptedRequest = interceptedRequest { + // Should have CloudKit-specific headers for server-to-server auth + #expect(interceptedRequest.headerFields[.cloudKitRequestKeyID] != nil) + #expect(interceptedRequest.headerFields[.cloudKitRequestISO8601Date] != nil) + #expect(interceptedRequest.headerFields[.cloudKitRequestSignatureV1] != nil) + } + } + + /// Tests intercept with server-to-server authentication and existing headers + @Test( + "Intercept request with server-to-server authentication and existing headers", + .enabled(if: Platform.isCryptoAvailable)) + internal func interceptWithServerToServerAuthenticationAndExistingHeaders() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("ServerToServerAuthManager is not available on this operating system.") + return + } + let keyID = "test-key-id-12345678" + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + privateKeyCallback: P256.Signing.PrivateKey() + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + var originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + originalRequest.headerFields[.userAgent] = "TestAgent/1.0" + originalRequest.headerFields[.contentType] = "application/json" + + var interceptedRequest: HTTPRequest? + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, _, _ in + interceptedRequest = request + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + + if let interceptedRequest = interceptedRequest { + // Should preserve existing headers + #expect(interceptedRequest.headerFields[.userAgent] == "TestAgent/1.0") + #expect(interceptedRequest.headerFields[.contentType] == "application/json") + + // Should add CloudKit-specific headers for server-to-server auth + #expect(interceptedRequest.headerFields[.cloudKitRequestKeyID] != nil) + #expect(interceptedRequest.headerFields[.cloudKitRequestISO8601Date] != nil) + #expect(interceptedRequest.headerFields[.cloudKitRequestSignatureV1] != nil) + } + } + + /// Tests intercept with server-to-server authentication for POST request + @Test( + "Intercept POST request with server-to-server authentication", + .enabled(if: Platform.isCryptoAvailable)) + internal func interceptPOSTWithServerToServerAuthentication() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("ServerToServerAuthManager is not available on this operating system.") + return + } + let keyID = "test-key-id-12345678" + + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + privateKeyCallback: P256.Signing.PrivateKey() + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .post, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/modify" + ) + + var interceptedRequest: HTTPRequest? + var interceptedBody: HTTPBody? + + let testBody = HTTPBody("test data") + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, body, _ in + interceptedRequest = request + interceptedBody = body + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: testBody, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + #expect(interceptedBody != nil) + + if let interceptedRequest = interceptedRequest { + // Should have CloudKit-specific headers for server-to-server auth + #expect(interceptedRequest.headerFields[.cloudKitRequestKeyID] != nil) + #expect(interceptedRequest.headerFields[.cloudKitRequestISO8601Date] != nil) + #expect(interceptedRequest.headerFields[.cloudKitRequestSignatureV1] != nil) + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+WebAuth.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+WebAuth.swift new file mode 100644 index 00000000..60676ca0 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+WebAuth.swift @@ -0,0 +1,105 @@ +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + /// Web Auth Token authentication tests + @Suite("Web Auth Token") + internal struct WebAuthToken { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + private static let testOperationID = TestConstants.operationID + + // MARK: - Web Auth Token Authentication Tests + + /// Tests intercept with web auth token authentication + @Test("Intercept request with web auth token authentication") + internal func interceptWithWebAuthTokenAuthentication() async throws { + let tokenManager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + var interceptedRequest: HTTPRequest? + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, _, _ in + interceptedRequest = request + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + + if let interceptedRequest = interceptedRequest { + let path = interceptedRequest.path ?? "" + #expect(path.contains("ckAPIToken=\(Self.validAPIToken)")) + #expect(path.contains("ckWebAuthToken=")) + } + } + + /// Tests intercept with web auth token authentication and existing query parameters + @Test("Intercept request with web auth token and existing query parameters") + internal func interceptWithWebAuthTokenAuthenticationAndExistingQuery() async throws { + let tokenManager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query?existingParam=value" + ) + + var interceptedRequest: HTTPRequest? + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + request, _, _ in + interceptedRequest = request + return (HTTPResponse(status: .ok), nil) + } + + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + + #expect(interceptedRequest != nil) + + if let interceptedRequest = interceptedRequest { + let path = interceptedRequest.path ?? "" + #expect(path.contains("existingParam=value")) + #expect(path.contains("ckAPIToken=\(Self.validAPIToken)")) + #expect(path.contains("ckWebAuthToken=")) + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests.swift new file mode 100644 index 00000000..255b9224 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests.swift @@ -0,0 +1,4 @@ +import Testing + +@Suite("Authentication Middleware") +internal enum AuthenticationMiddlewareTests {} diff --git a/Tests/MistKitTests/Authentication/Middleware/Error/AuthenticationMiddlewareTests+Error.swift b/Tests/MistKitTests/Authentication/Middleware/Error/AuthenticationMiddlewareTests+Error.swift new file mode 100644 index 00000000..698156f2 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/Error/AuthenticationMiddlewareTests+Error.swift @@ -0,0 +1,173 @@ +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + /// Error handling tests for AuthenticationMiddleware + @Suite("Error", .enabled(if: Platform.isCryptoAvailable)) + internal struct Error { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let testOperationID = TestConstants.operationID + + // MARK: - Token Validation Error Tests + + /// Tests intercept with invalid token manager + @Test("Intercept request with invalid token manager") + internal func interceptWithInvalidTokenManager() async throws { + let mockTokenManager = MockTokenManagerWithoutCredentials() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + (HTTPResponse(status: .ok), nil) + } + + do { + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests intercept with token manager that throws authentication error + @Test("Intercept request with token manager that throws authentication error") + internal func interceptWithTokenManagerAuthenticationError() async throws { + let mockTokenManager = MockTokenManagerWithAuthenticationError() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + (HTTPResponse(status: .ok), nil) + } + + do { + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + Issue.record("Should have thrown TokenManagerError.authenticationFailed") + } catch let error as TokenManagerError { + if case .authenticationFailed = error { + // Expected + } else { + Issue.record("Expected authenticationFailed error, got: \(error)") + } + } + } + + /// Tests intercept with token manager that throws network error + @Test("Intercept request with token manager that throws network error") + internal func interceptWithTokenManagerNetworkError() async throws { + let mockTokenManager = MockTokenManagerWithNetworkError() + let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + (HTTPResponse(status: .ok), nil) + } + + do { + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + Issue.record("Should have thrown TokenManagerError.networkError") + } catch let error as TokenManagerError { + if case .networkError = error { + // Expected + } else { + Issue.record("Expected networkError, got: \(error)") + } + } + } + + /// Tests that errors from next middleware are properly propagated + @Test("Error propagation from next middleware") + internal func errorPropagationFromNextMiddleware() async throws { + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + let originalRequest = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + throw NSError( + domain: "TestError", + code: 500, + userInfo: [ + NSLocalizedDescriptionKey: "Test error from next middleware" + ] + ) + } + + do { + _ = try await middleware.intercept( + originalRequest, + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: next + ) + Issue.record("Should have thrown error from next middleware") + } catch let error as NSError { + #expect(error.domain == "TestError") + #expect(error.code == 500) + #expect(error.localizedDescription == "Test error from next middleware") + } + } + } +} + +// MARK: - Mock Token Managers for Error Testing diff --git a/Tests/MistKitTests/Authentication/Middleware/Error/MockTokenManagerWithAuthenticationError.swift b/Tests/MistKitTests/Authentication/Middleware/Error/MockTokenManagerWithAuthenticationError.swift new file mode 100644 index 00000000..875b8bb7 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/Error/MockTokenManagerWithAuthenticationError.swift @@ -0,0 +1,23 @@ +// +// MockTokenManagerWithAuthenticationError.swift +// MistKit +// +// Created by Leo Dion on 9/25/25. +// + +@testable import MistKit + +/// Mock TokenManager that throws authentication failed error +internal final class MockTokenManagerWithAuthenticationError: TokenManager { + internal var hasCredentials: Bool { + get async { true } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + throw TokenManagerError.authenticationFailed(.serverRejected(statusCode: 401, message: nil)) + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + throw TokenManagerError.authenticationFailed(.serverRejected(statusCode: 401, message: nil)) + } +} diff --git a/Tests/MistKitTests/Authentication/Middleware/Error/MockTokenManagerWithNetworkError.swift b/Tests/MistKitTests/Authentication/Middleware/Error/MockTokenManagerWithNetworkError.swift new file mode 100644 index 00000000..0a1adc58 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/Error/MockTokenManagerWithNetworkError.swift @@ -0,0 +1,25 @@ +// +// MockTokenManagerWithNetworkError.swift +// MistKit +// +// Created by Leo Dion on 9/25/25. +// + +import Foundation + +@testable import MistKit + +/// Mock TokenManager that throws network error +internal final class MockTokenManagerWithNetworkError: TokenManager { + internal var hasCredentials: Bool { + get async { true } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + throw TokenManagerError.networkError(.notConnectedToInternet) + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + throw TokenManagerError.networkError(.notConnectedToInternet) + } +} diff --git a/Tests/MistKitTests/NetworkError/Recovery/RecoveryTests.swift b/Tests/MistKitTests/Authentication/NetworkError/RecoveryTests.swift similarity index 88% rename from Tests/MistKitTests/NetworkError/Recovery/RecoveryTests.swift rename to Tests/MistKitTests/Authentication/NetworkError/RecoveryTests.swift index c4280f1f..286a4734 100644 --- a/Tests/MistKitTests/NetworkError/Recovery/RecoveryTests.swift +++ b/Tests/MistKitTests/Authentication/NetworkError/RecoveryTests.swift @@ -11,7 +11,7 @@ internal enum NetworkErrorTests {} extension NetworkErrorTests { /// Network error recovery and retry mechanism tests - @Suite("Recovery Tests", .enabled(if: Platform.isCryptoAvailable)) + @Suite("Recovery", .enabled(if: Platform.isCryptoAvailable)) internal struct RecoveryTests { // MARK: - Error Recovery Tests @@ -38,8 +38,8 @@ extension NetworkErrorTests { _ = try await middleware.intercept( originalRequest, body: nil, - baseURL: URL.MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) Issue.record("Should have thrown TokenManagerError.networkError") @@ -55,8 +55,8 @@ extension NetworkErrorTests { let response = try await middleware.intercept( originalRequest, body: nil, - baseURL: URL.MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) @@ -89,8 +89,8 @@ extension NetworkErrorTests { let response = try await middleware.intercept( originalRequest, body: nil, - baseURL: URL.MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) #expect(response.0.status == .ok) @@ -133,14 +133,14 @@ extension NetworkErrorTests { _ = try await middleware.intercept( originalRequest, body: nil, - baseURL: URL.MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) Issue.record("Should have thrown TokenManagerError.networkError") } catch let error as TokenManagerError { - if case .networkError(let underlyingError) = error { - #expect(underlyingError.localizedDescription.contains("Timeout")) + if case .networkError(let reason) = error { + #expect(reason.description.contains("timed out")) } else { Issue.record("Expected networkError, got: \(error)") } diff --git a/Tests/MistKitTests/NetworkError/Simulation/SimulationTests.swift b/Tests/MistKitTests/Authentication/NetworkError/SimulationTests.swift similarity index 85% rename from Tests/MistKitTests/NetworkError/Simulation/SimulationTests.swift rename to Tests/MistKitTests/Authentication/NetworkError/SimulationTests.swift index 0dd9043f..74711c29 100644 --- a/Tests/MistKitTests/NetworkError/Simulation/SimulationTests.swift +++ b/Tests/MistKitTests/Authentication/NetworkError/SimulationTests.swift @@ -8,7 +8,7 @@ import Testing extension NetworkErrorTests { /// Network error simulation tests - @Suite("Simulation Tests", .enabled(if: Platform.isCryptoAvailable)) + @Suite("Simulation", .enabled(if: Platform.isCryptoAvailable)) internal struct SimulationTests { // MARK: - Network Error Simulation Tests @@ -34,14 +34,14 @@ extension NetworkErrorTests { _ = try await middleware.intercept( originalRequest, body: nil, - baseURL: .MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) Issue.record("Should have thrown TokenManagerError.networkError") } catch let error as TokenManagerError { - if case .networkError(let underlyingError) = error { - #expect(underlyingError.localizedDescription.contains("Timeout")) + if case .networkError(let reason) = error { + #expect(reason.description.contains("timed out")) } else { Issue.record("Expected networkError, got: \(error)") } @@ -70,14 +70,14 @@ extension NetworkErrorTests { _ = try await middleware.intercept( originalRequest, body: nil, - baseURL: .MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) Issue.record("Should have thrown TokenManagerError.networkError") } catch let error as TokenManagerError { - if case .networkError(let underlyingError) = error { - #expect(underlyingError.localizedDescription.contains("Connection")) + if case .networkError(let reason) = error { + #expect(reason.description.contains("internet")) } else { Issue.record("Expected networkError, got: \(error)") } @@ -111,8 +111,8 @@ extension NetworkErrorTests { _ = try await middleware.intercept( originalRequest, body: nil, - baseURL: .MistKit.cloudKitAPI, - operationID: "test-operation", + baseURL: CloudKitService.baseURL, + operationID: TestConstants.operationID, next: next ) successCount += 1 diff --git a/Tests/MistKitTests/Authentication/NetworkError/StorageTests.swift b/Tests/MistKitTests/Authentication/NetworkError/StorageTests.swift new file mode 100644 index 00000000..629315e5 --- /dev/null +++ b/Tests/MistKitTests/Authentication/NetworkError/StorageTests.swift @@ -0,0 +1,108 @@ +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension NetworkErrorTests { + /// Network error storage tests + @Suite("Storage", .enabled(if: Platform.isCryptoAvailable)) + internal struct StorageTests { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + + // MARK: - Token Storage Tests + + /// Tests token storage with network errors + @Test("Token storage with network errors") + internal func tokenStorageWithNetworkErrors() async throws { + let storage = InMemoryTokenStorage() + + // Store authenticator + let authenticator = try APITokenAuthenticator(token: Self.validAPIToken) + try await storage.store(authenticator, identifier: "test-key") + + // Retrieve authenticator + let retrieved = try await storage.retrieve(identifier: "test-key") + let api = try #require(retrieved as? APITokenAuthenticator) + #expect(api.token == Self.validAPIToken) + } + + /// Tests token storage persistence across network failures + @Test("Token storage persistence across network failures") + internal func tokenStoragePersistenceAcrossNetworkFailures() async throws { + let storage = InMemoryTokenStorage() + + let authenticator = try APITokenAuthenticator(token: Self.validAPIToken) + try await storage.store(authenticator, identifier: "persistent-key") + + let retrieved = try await storage.retrieve(identifier: "persistent-key") + let api = try #require(retrieved as? APITokenAuthenticator) + #expect(api.token == Self.validAPIToken) + } + + /// Tests token storage cleanup after network errors + @Test("Token storage cleanup after network errors") + internal func tokenStorageCleanupAfterNetworkErrors() async throws { + let storage = InMemoryTokenStorage() + + let authenticator = try APITokenAuthenticator(token: Self.validAPIToken) + try await storage.store(authenticator, identifier: "cleanup-key") + + let initialRetrieval = try await storage.retrieve(identifier: "cleanup-key") + #expect(initialRetrieval != nil) + + try await storage.remove(identifier: "cleanup-key") + + let finalRetrieval = try await storage.retrieve(identifier: "cleanup-key") + #expect(finalRetrieval == nil) + } + + /// Tests concurrent token storage operations + @Test("Concurrent token storage operations") + internal func concurrentTokenStorageOperations() async throws { + let storage = InMemoryTokenStorage() + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await storage.storeToken(key: "concurrent-1", token: TestConstants.apiToken) + } + group.addTask { + try await storage.storeToken(key: "concurrent-2", token: TestConstants.apiToken) + } + group.addTask { + try await storage.storeToken(key: "concurrent-3", token: TestConstants.apiToken) + } + + for try await _ in group {} + } + + let token1 = try await storage.retrieve(identifier: "concurrent-1") + let token2 = try await storage.retrieve(identifier: "concurrent-2") + let token3 = try await storage.retrieve(identifier: "concurrent-3") + + #expect(token1 != nil) + #expect(token2 != nil) + #expect(token3 != nil) + } + + /// Tests token storage with expiration + @Test("Token storage with expiration") + internal func tokenStorageWithExpiration() async throws { + let storage = InMemoryTokenStorage() + + let authenticator = try APITokenAuthenticator(token: Self.validAPIToken) + try await storage.store(authenticator, identifier: "expiring-key") + + let initialRetrieval = try await storage.retrieve(identifier: "expiring-key") + #expect(initialRetrieval != nil) + + let finalRetrieval = try await storage.retrieve(identifier: "expiring-key") + #expect(finalRetrieval != nil) + } + } +} diff --git a/Tests/MistKitTests/Authentication/Protocol/AuthenticationMethod+TestHelpers.swift b/Tests/MistKitTests/Authentication/Protocol/AuthenticationMethod+TestHelpers.swift deleted file mode 100644 index 3706aceb..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/AuthenticationMethod+TestHelpers.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension AuthenticationMethod { - /// Test helper to process method and return method type - internal func processMethod() async -> String { - methodType - } -} diff --git a/Tests/MistKitTests/Authentication/Protocol/MockTokenManager.swift b/Tests/MistKitTests/Authentication/Protocol/MockTokenManager.swift deleted file mode 100644 index 3d7e811b..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/MockTokenManager.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// conformance.swift -// MistKit -// -// Created by Leo Dion on 9/25/25. -// - -@testable import MistKit - -/// Mock implementation of TokenManager for testing protocol conformance -internal final class MockTokenManager: TokenManager { - internal var hasCredentials: Bool { - get async { true } - } - - internal func validateCredentials() async throws(TokenManagerError) -> Bool { - true - } - - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - TokenCredentials.apiToken("mock-token") - } -} diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenCredentials+TestHelpers.swift b/Tests/MistKitTests/Authentication/Protocol/TokenCredentials+TestHelpers.swift deleted file mode 100644 index a3ea67d6..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/TokenCredentials+TestHelpers.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension TokenCredentials { - /// Test helper to process credentials and return method type - internal func processCredentials() async -> String { - methodType - } -} diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenManagerAuthenticationMethodTests.swift b/Tests/MistKitTests/Authentication/Protocol/TokenManagerAuthenticationMethodTests.swift deleted file mode 100644 index 12adf6ff..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/TokenManagerAuthenticationMethodTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("Token Manager - Authentication Method") -/// Test suite for AuthenticationMethod enum and related functionality -internal struct TokenManagerAuthenticationMethodTests { - // MARK: - AuthenticationMethod Tests - - /// Tests AuthenticationMethod enum case creation and equality - @Test("AuthenticationMethod enum case creation and equality") - internal func authenticationMethodCases() { - // Test API token case - let apiToken = AuthenticationMethod.apiToken("test-token-123") - if case .apiToken(let token) = apiToken { - #expect(token == "test-token-123") - } else { - Issue.record("Expected apiToken case") - } - - // Test web auth token case - let webAuth = AuthenticationMethod.webAuthToken( - apiToken: "api-123", - webToken: "web-456" - ) - if case .webAuthToken(let api, let web) = webAuth { - #expect(api == "api-123") - #expect(web == "web-456") - } else { - Issue.record("Expected webAuthToken case") - } - - // Test server-to-server case - let keyData = Data("test-key".utf8) - let serverAuth = AuthenticationMethod.serverToServer( - keyID: "key-789", - privateKey: keyData - ) - if case .serverToServer(let keyID, let privateKey) = serverAuth { - #expect(keyID == "key-789") - #expect(privateKey == keyData) - } else { - Issue.record("Expected serverToServer case") - } - } - - /// Tests AuthenticationMethod computed properties - @Test("AuthenticationMethod computed properties") - internal func authenticationMethodProperties() { - let apiToken = AuthenticationMethod.apiToken("api-123") - let webAuth = AuthenticationMethod.webAuthToken( - apiToken: "api-456", - webToken: "web-789" - ) - let serverAuth = AuthenticationMethod.serverToServer( - keyID: "key-abc", - privateKey: Data() - ) - - // Test apiToken property - #expect(apiToken.apiToken == "api-123") - #expect(webAuth.apiToken == "api-456") - #expect(serverAuth.apiToken == nil) - - // Test webAuthToken property - #expect(apiToken.webAuthToken == nil) - #expect(webAuth.webAuthToken == "web-789") - #expect(serverAuth.webAuthToken == nil) - - // Test serverKeyID property - #expect(apiToken.serverKeyID == nil) - #expect(webAuth.serverKeyID == nil) - #expect(serverAuth.serverKeyID == "key-abc") - - // Test privateKeyData property - #expect(apiToken.privateKeyData == nil) - #expect(webAuth.privateKeyData == nil) - #expect(serverAuth.privateKeyData != nil) - - // Test methodType property - #expect(apiToken.methodType == "api-token") - #expect(webAuth.methodType == "web-auth-token") - #expect(serverAuth.methodType == "server-to-server") - } - - /// Tests AuthenticationMethod Equatable conformance - @Test("AuthenticationMethod Equatable conformance") - internal func authenticationMethodEquality() { - let apiToken1 = AuthenticationMethod.apiToken("same-token") - let apiToken2 = AuthenticationMethod.apiToken("same-token") - let apiToken3 = AuthenticationMethod.apiToken("different-token") - - #expect(apiToken1 == apiToken2) - #expect(apiToken1 != apiToken3) - - let webAuth1 = AuthenticationMethod.webAuthToken( - apiToken: "api", - webToken: "web" - ) - let webAuth2 = AuthenticationMethod.webAuthToken( - apiToken: "api", - webToken: "web" - ) - let webAuth3 = AuthenticationMethod.webAuthToken( - apiToken: "api", - webToken: "different" - ) - - #expect(webAuth1 == webAuth2) - #expect(webAuth1 != webAuth3) - - let keyData = Data("test".utf8) - let serverAuth1 = AuthenticationMethod.serverToServer( - keyID: "key1", - privateKey: keyData - ) - let serverAuth2 = AuthenticationMethod.serverToServer( - keyID: "key1", - privateKey: keyData - ) - let serverAuth3 = AuthenticationMethod.serverToServer( - keyID: "key2", - privateKey: keyData - ) - - #expect(serverAuth1 == serverAuth2) - #expect(serverAuth1 != serverAuth3) - } -} diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenManagerProtocolTests.swift b/Tests/MistKitTests/Authentication/Protocol/TokenManagerProtocolTests.swift deleted file mode 100644 index fc28da86..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/TokenManagerProtocolTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("Token Manager - Protocol Conformance") -/// Test suite for TokenManager protocol conformance and Sendable compliance -internal struct TokenManagerProtocolTests { - // MARK: - TokenManager Protocol Tests - - /// Tests TokenManager protocol conformance with mock implementation - @Test("TokenManager protocol conformance with mock implementation") - internal func tokenManagerProtocolConformance() async throws { - let mockManager = MockTokenManager() - - // Test protocol methods can be called - let isValid = try await mockManager.validateCredentials() - #expect(isValid == true) - - let credentials = try await mockManager.getCurrentCredentials() - #expect(credentials != nil) - - // Test computed properties - let hasCredentials = await mockManager.hasCredentials - #expect(hasCredentials == true) - } - - // MARK: - Sendable Compliance Tests - - /// Tests that all types are Sendable and can be used across async boundaries - @Test("TokenManager sendable compliance") - internal func sendableCompliance() async { - let method = AuthenticationMethod.apiToken("test") - let credentials = TokenCredentials(method: method) - let error = TokenManagerError.tokenExpired - - // Test concurrent access patterns - async let task1 = credentials.processCredentials() - async let task2 = method.processMethod() - async let task3 = error.processError() - - let results = await (task1, task2, task3) - #expect(results.0 == "api-token") - #expect(results.1 == "api-token") - #expect(results.2.isEmpty == false) - } -} - -// MARK: - Mock TokenManager Implementation diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenManagerTests.swift b/Tests/MistKitTests/Authentication/Protocol/TokenManagerTests.swift deleted file mode 100644 index 68bdb527..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/TokenManagerTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("Token Manager") -/// Test suite for TokenManager protocol and related types -internal struct TokenManagerTests { - // MARK: - Integration Tests - - /// Tests integration between different TokenManager components - @Test("TokenManager integration test") - internal func tokenManagerIntegration() async throws { - let method = AuthenticationMethod.apiToken("integration-test-token") - let credentials = TokenCredentials(method: method) - let mockManager = MockTokenManager() - - // Test that all components work together - let isValid = try await mockManager.validateCredentials() - #expect(isValid == true) - - let retrievedCredentials = try await mockManager.getCurrentCredentials() - #expect(retrievedCredentials != nil) - #expect(retrievedCredentials?.methodType == "api-token") - - // Test that credentials can be processed - let methodType = credentials.methodType - #expect(methodType == "api-token") - } -} diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenManagerTokenCredentialsTests.swift b/Tests/MistKitTests/Authentication/Protocol/TokenManagerTokenCredentialsTests.swift deleted file mode 100644 index aabfed7e..00000000 --- a/Tests/MistKitTests/Authentication/Protocol/TokenManagerTokenCredentialsTests.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("Token Manager - Token Credentials") -/// Test suite for TokenCredentials and related functionality -internal struct TokenManagerTokenCredentialsTests { - // MARK: - TokenCredentials Tests - - /// Tests TokenCredentials initialization and properties - @Test("TokenCredentials initialization and properties") - internal func tokenCredentialsInitialization() { - let method = AuthenticationMethod.apiToken("test-token") - let metadata = ["created": "2025-01-01", "environment": "test"] - - let credentials = TokenCredentials(method: method, metadata: metadata) - - #expect(credentials.method == method) - #expect(credentials.metadata.count == 2) - #expect(credentials.metadata["created"] == "2025-01-01") - #expect(credentials.metadata["environment"] == "test") - } - - /// Tests TokenCredentials convenience initializers - @Test("TokenCredentials convenience initializers") - internal func tokenCredentialsConvenienceInitializers() { - // Test apiToken convenience initializer - let apiCredentials = TokenCredentials.apiToken("api-token-123") - if case .apiToken(let token) = apiCredentials.method { - #expect(token == "api-token-123") - } else { - Issue.record("Expected apiToken method") - } - - // Test webAuthToken convenience initializer - let webCredentials = TokenCredentials.webAuthToken( - apiToken: "api-456", - webToken: "web-789" - ) - if case .webAuthToken(let api, let web) = webCredentials.method { - #expect(api == "api-456") - #expect(web == "web-789") - } else { - Issue.record("Expected webAuthToken method") - } - - // Test serverToServer convenience initializer - let keyData = Data("private-key".utf8) - let serverCredentials = TokenCredentials.serverToServer( - keyID: "server-key-id", - privateKey: keyData - ) - if case .serverToServer(let keyID, let privateKey) = serverCredentials.method { - #expect(keyID == "server-key-id") - #expect(privateKey == keyData) - } else { - Issue.record("Expected serverToServer method") - } - } - - /// Tests TokenCredentials computed properties - @Test("TokenCredentials computed properties") - internal func tokenCredentialsProperties() { - let apiCredentials = TokenCredentials.apiToken("test") - let webCredentials = TokenCredentials.webAuthToken( - apiToken: "api", - webToken: "web" - ) - let serverCredentials = TokenCredentials.serverToServer( - keyID: "key", - privateKey: Data() - ) - - // Test supportsUserOperations - #expect(apiCredentials.supportsUserOperations == false) - #expect(webCredentials.supportsUserOperations == true) - #expect(serverCredentials.supportsUserOperations == false) - - // Test methodType - #expect(apiCredentials.methodType == "api-token") - #expect(webCredentials.methodType == "web-auth-token") - #expect(serverCredentials.methodType == "server-to-server") - } - - /// Tests TokenCredentials Equatable conformance - @Test("TokenCredentials Equatable conformance") - internal func tokenCredentialsEquality() { - let method1 = AuthenticationMethod.apiToken("same-token") - let method2 = AuthenticationMethod.apiToken("same-token") - let method3 = AuthenticationMethod.apiToken("different-token") - - let credentials1 = TokenCredentials(method: method1) - let credentials2 = TokenCredentials(method: method2) - let credentials3 = TokenCredentials(method: method3) - let credentials4 = TokenCredentials( - method: method1, - metadata: ["test": "value"] - ) - - #expect(credentials1 == credentials2) - #expect(credentials1 != credentials3) - #expect(credentials1 != credentials4) // Different metadata - } -} diff --git a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManager+TestHelpers.swift b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManager+TestHelpers.swift index 36adccc7..dec97e9d 100644 --- a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManager+TestHelpers.swift +++ b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManager+TestHelpers.swift @@ -3,10 +3,8 @@ import Testing @testable import MistKit -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension ServerToServerAuthManager { /// Test helper to validate credentials and return a boolean result - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal func validateManager() async -> Bool { do { return try await validateCredentials() @@ -15,18 +13,16 @@ extension ServerToServerAuthManager { } } - /// Test helper to get credentials and return them or nil - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal func getCredentialsFromManager() async -> TokenCredentials? { + /// Test helper to get the current authenticator or nil on failure. + internal func authenticatorFromManager() async -> (any Authenticator)? { do { - return try await getCurrentCredentials() + return try await currentAuthenticator() } catch { return nil } } /// Test helper to check if credentials are available - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal func checkHasCredentials() async -> Bool { await hasCredentials } diff --git a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+InitializationTests.swift b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+InitializationTests.swift index 0568ccf9..ed74025a 100644 --- a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+InitializationTests.swift +++ b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+InitializationTests.swift @@ -19,10 +19,16 @@ extension ServerToServerAuthManagerTests { return privateKey.rawRepresentation } - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) private static func generateTestPEMString() throws -> String { let privateKey = try generateTestPrivateKey() - return privateKey.pemRepresentation + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + return privateKey.pemRepresentation + } else { + Issue.record( + "pemRepresentation requires macOS 11.0+ / iOS 14.0+ / tvOS 14.0+ / watchOS 7.0+" + ) + return "" + } } // MARK: - Initialization Tests @@ -44,18 +50,11 @@ extension ServerToServerAuthManagerTests { // Verify manager properties #expect(manager.keyID == keyID) - // Test that we can get credentials - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .serverToServer(let storedKeyID, let storedPrivateKey) = credentials.method { - #expect(storedKeyID == keyID) - #expect(storedPrivateKey == manager.privateKeyData) - } else { - Issue.record("Expected .serverToServer method") - } - } + // Test that we can get the authenticator + let authenticator = try await manager.currentAuthenticator() + let s2s = try #require(authenticator as? ServerToServerAuthenticator) + #expect(s2s.keyID == keyID) + #expect(s2s.privateKey.rawRepresentation == manager.privateKeyData) } /// Tests ServerToServerAuthManager initialization with private key data @@ -76,18 +75,10 @@ extension ServerToServerAuthManagerTests { // Verify manager properties #expect(manager.keyID == keyID) - // Test that we can get credentials - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .serverToServer(let storedKeyID, let storedPrivateKey) = credentials.method { - #expect(storedKeyID == keyID) - #expect(storedPrivateKey == privateKeyData) - } else { - Issue.record("Expected .serverToServer method") - } - } + let authenticator = try await manager.currentAuthenticator() + let s2s = try #require(authenticator as? ServerToServerAuthenticator) + #expect(s2s.keyID == keyID) + #expect(s2s.privateKey.rawRepresentation == privateKeyData) } /// Tests ServerToServerAuthManager initialization with PEM string @@ -108,17 +99,9 @@ extension ServerToServerAuthManagerTests { // Verify manager properties #expect(manager.keyID == keyID) - // Test that we can get credentials - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .serverToServer(let storedKeyID, _) = credentials.method { - #expect(storedKeyID == keyID) - } else { - Issue.record("Expected .serverToServer method") - } - } + let authenticator = try await manager.currentAuthenticator() + let s2s = try #require(authenticator as? ServerToServerAuthenticator) + #expect(s2s.keyID == keyID) } /// Tests ServerToServerAuthManager initialization with storage diff --git a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+PrivateKeyTests.swift b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+PrivateKeyTests.swift index 449332ed..391b3905 100644 --- a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+PrivateKeyTests.swift +++ b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+PrivateKeyTests.swift @@ -6,7 +6,7 @@ import Testing extension ServerToServerAuthManagerTests { /// Private key validation tests for ServerToServerAuthManager - @Suite("Private Key Tests", .enabled(if: Platform.isCryptoAvailable)) + @Suite("Private Key", .enabled(if: Platform.isCryptoAvailable)) internal struct PrivateKeyTests { private static func generateTestPrivateKeyClosure() -> @Sendable () throws -> @@ -63,22 +63,15 @@ extension ServerToServerAuthManagerTests { #expect(isValid2 == true) // But they should have different private keys - let credentials1 = try await manager1.getCurrentCredentials() - let credentials2 = try await manager2.getCurrentCredentials() - - #expect(credentials1 != nil) - #expect(credentials2 != nil) + let auth1 = try #require( + try await manager1.currentAuthenticator() as? ServerToServerAuthenticator + ) + let auth2 = try #require( + try await manager2.currentAuthenticator() as? ServerToServerAuthenticator + ) - if let cred1 = credentials1, let cred2 = credentials2 { - if case .serverToServer(let keyID1, let privateKeyData1) = cred1.method, - case .serverToServer(let keyID2, let privateKeyData2) = cred2.method - { - #expect(keyID1 == keyID2) // Same key ID - #expect(privateKeyData1 != privateKeyData2) // Different private keys - } else { - Issue.record("Expected serverToServer method") - } - } + #expect(auth1.keyID == auth2.keyID) + #expect(auth1.privateKey.rawRepresentation != auth2.privateKey.rawRepresentation) } // MARK: - Sendable Compliance Tests @@ -98,7 +91,7 @@ extension ServerToServerAuthManagerTests { // Test concurrent access patterns async let task1 = manager.validateManager() - async let task2 = manager.getCredentialsFromManager() + async let task2 = manager.authenticatorFromManager() async let task3 = manager.checkHasCredentials() let results = await (task1, task2, task3) diff --git a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+ValidationTests.swift b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+ValidationTests.swift index 86641900..888c80da 100644 --- a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+ValidationTests.swift +++ b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthManagerTests+ValidationTests.swift @@ -87,9 +87,9 @@ extension ServerToServerAuthManagerTests { #expect(isValid == true) } - /// Tests getCurrentCredentials with valid credentials - @Test("getCurrentCredentials with valid credentials", .enabled(if: Platform.isCryptoAvailable)) - internal func getCurrentCredentialsValidCredentials() async throws { + /// Tests currentAuthenticator with valid credentials + @Test("currentAuthenticator with valid credentials", .enabled(if: Platform.isCryptoAvailable)) + internal func currentAuthenticatorValidCredentials() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("ServerToServerAuthManager is not available on this operating system.") return @@ -100,23 +100,16 @@ extension ServerToServerAuthManagerTests { privateKeyCallback: try Self.generateTestPrivateKey() ) - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .serverToServer(let storedKeyID, let storedPrivateKey) = credentials.method { - #expect(storedKeyID == keyID) - #expect(storedPrivateKey == manager.privateKeyData) - } else { - Issue.record("Expected .serverToServer method") - } - } + let authenticator = try await manager.currentAuthenticator() + let s2s = try #require(authenticator as? ServerToServerAuthenticator) + #expect(s2s.keyID == keyID) + #expect(s2s.privateKey.rawRepresentation == manager.privateKeyData) } - /// Tests getCurrentCredentials with invalid credentials + /// Tests currentAuthenticator with invalid credentials @Test( - "getCurrentCredentials with invalid credentials", .enabled(if: Platform.isCryptoAvailable)) - internal func getCurrentCredentialsInvalidCredentials() async throws { + "currentAuthenticator with invalid credentials", .enabled(if: Platform.isCryptoAvailable)) + internal func currentAuthenticatorInvalidCredentials() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("ServerToServerAuthManager is not available on this operating system.") return @@ -131,7 +124,7 @@ extension ServerToServerAuthManagerTests { ) do { - _ = try await manager.getCurrentCredentials() + _ = try await manager.currentAuthenticator() Issue.record("Should have thrown TokenManagerError.invalidCredentials") } catch { switch error { diff --git a/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthenticatorTests.swift b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthenticatorTests.swift new file mode 100644 index 00000000..88bb5351 --- /dev/null +++ b/Tests/MistKitTests/Authentication/ServerToServer/ServerToServerAuthenticatorTests.swift @@ -0,0 +1,155 @@ +// +// ServerToServerAuthenticatorTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// + +import Crypto +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +/// Per-authenticator tests for `ServerToServerAuthenticator`. +@Suite("ServerToServerAuthenticator", .enabled(if: Platform.isCryptoAvailable)) +internal struct ServerToServerAuthenticatorTests { + // MARK: - authenticate(request:body:) + + @Test("authenticate adds CloudKit signature headers") + internal func addsSignatureHeaders() async throws { + let authenticator = try ServerToServerAuthenticator( + keyID: "test-key-id-12345678", + privateKey: P256.Signing.PrivateKey() + ) + var request = HTTPRequest( + method: .post, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.example/development/public/records/query" + ) + var body: HTTPBody? + + try await authenticator.authenticate(request: &request, body: &body) + + #expect(request.headerFields[.cloudKitRequestKeyID] == "test-key-id-12345678") + #expect(request.headerFields[.cloudKitRequestISO8601Date] != nil) + #expect(request.headerFields[.cloudKitRequestSignatureV1] != nil) + } + + @Test("authenticate buffers body so downstream sees the same bytes") + internal func bufferReplacesSingleIterationBody() async throws { + let authenticator = try ServerToServerAuthenticator( + keyID: "test-key-id-12345678", + privateKey: P256.Signing.PrivateKey() + ) + var request = HTTPRequest( + method: .post, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/foo" + ) + let originalBytes = Data("hello-world".utf8) + // .single iteration behaviour drains after one read. The authenticator + // must replace `body` with a fresh HTTPBody backed by buffered Data so + // downstream still receives the bytes. + var body: HTTPBody? = HTTPBody(originalBytes, length: .known(.init(originalBytes.count))) + + try await authenticator.authenticate(request: &request, body: &body) + + let downstreamBody = try #require(body) + let downstreamData = try await Data(collecting: downstreamBody, upTo: 1_024) + #expect(downstreamData == originalBytes) + } + + // MARK: - init validation + + @Test("init throws on empty key ID") + internal func emptyKeyIDThrows() { + do { + _ = try ServerToServerAuthenticator( + keyID: "", + privateKey: P256.Signing.PrivateKey() + ) + Issue.record("Expected init to throw") + } catch { + if case .invalidCredentials(.keyIdEmpty) = error { + // Expected + } else { + Issue.record("Unexpected error: \(error)") + } + } + } + + @Test("init throws on key ID shorter than 8 characters") + internal func shortKeyIDThrows() { + do { + _ = try ServerToServerAuthenticator( + keyID: "short", + privateKey: P256.Signing.PrivateKey() + ) + Issue.record("Expected init to throw") + } catch { + if case .invalidCredentials(.keyIdTooShort) = error { + // Expected + } else { + Issue.record("Unexpected error: \(error)") + } + } + } + + // MARK: - serialization round-trip + + @Test("encoded then init(decoding:) round-trips key + bodyBufferLimit") + internal func encodingRoundTrip() throws { + let key = P256.Signing.PrivateKey() + let original = try ServerToServerAuthenticator( + keyID: "test-key-id-12345678", + privateKey: key, + bodyBufferLimit: 2_048 + ) + let data = try original.encoded() + let restored = try ServerToServerAuthenticator(decoding: data) + #expect(restored.keyID == original.keyID) + #expect(restored.privateKey.rawRepresentation == key.rawRepresentation) + #expect(restored.bodyBufferLimit == 2_048) + } + + @Test("storageKey is stable") + internal func storageKey() { + #expect(ServerToServerAuthenticator.storageKey == "server-to-server") + } + + @Test("defaultStorageIdentifier uses keyID") + internal func defaultStorageIdentifier() throws { + let authenticator = try ServerToServerAuthenticator( + keyID: "test-key-id-12345678", + privateKey: P256.Signing.PrivateKey() + ) + #expect(authenticator.defaultStorageIdentifier == "s2s-test-key-id-12345678") + } + + @Test("authenticate throws when body exceeds bodyBufferLimit") + internal func authenticateThrowsOnOversizeBody() async throws { + let authenticator = try ServerToServerAuthenticator( + keyID: "test-key-id-12345678", + privateKey: P256.Signing.PrivateKey(), + bodyBufferLimit: 16 + ) + var request = HTTPRequest( + method: .post, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/foo" + ) + let oversized = Data(repeating: 0x41, count: 1_024) + var body: HTTPBody? = HTTPBody(oversized, length: .known(.init(oversized.count))) + + await #expect(throws: (any Error).self) { + try await authenticator.authenticate(request: &request, body: &body) + } + } +} diff --git a/Tests/MistKitTests/Authentication/TokenManager/MockTokenManager.swift b/Tests/MistKitTests/Authentication/TokenManager/MockTokenManager.swift new file mode 100644 index 00000000..73451aff --- /dev/null +++ b/Tests/MistKitTests/Authentication/TokenManager/MockTokenManager.swift @@ -0,0 +1,23 @@ +// +// conformance.swift +// MistKit +// +// Created by Leo Dion on 9/25/25. +// + +@testable import MistKit + +/// Mock implementation of TokenManager for testing protocol conformance +internal final class MockTokenManager: TokenManager { + internal var hasCredentials: Bool { + get async { true } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + true + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try APITokenAuthenticator(token: TestConstants.apiToken) + } +} diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenManagerError+TestHelpers.swift b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerError+TestHelpers.swift similarity index 100% rename from Tests/MistKitTests/Authentication/Protocol/TokenManagerError+TestHelpers.swift rename to Tests/MistKitTests/Authentication/TokenManager/TokenManagerError+TestHelpers.swift diff --git a/Tests/MistKitTests/Authentication/Protocol/TokenManagerErrorTests.swift b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerErrorTests.swift similarity index 87% rename from Tests/MistKitTests/Authentication/Protocol/TokenManagerErrorTests.swift rename to Tests/MistKitTests/Authentication/TokenManager/TokenManagerErrorTests.swift index 4cc4084f..de862839 100644 --- a/Tests/MistKitTests/Authentication/Protocol/TokenManagerErrorTests.swift +++ b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerErrorTests.swift @@ -12,10 +12,12 @@ internal struct TokenManagerErrorTests { @Test("TokenManagerError cases and localized descriptions") internal func tokenManagerError() { let invalidError = TokenManagerError.invalidCredentials(.apiTokenInvalidFormat) - let authError = TokenManagerError.authenticationFailed(underlying: nil) + let authError = TokenManagerError.authenticationFailed( + .serverRejected(statusCode: 401, message: nil) + ) let expiredError = TokenManagerError.tokenExpired let networkError = TokenManagerError.networkError( - underlying: NSError(domain: "test", code: 123, userInfo: nil) + .other(NSError(domain: "test", code: 123, userInfo: nil)) ) let internalError = TokenManagerError.internalError(.noCredentialsAvailable) diff --git a/Tests/MistKitTests/Authentication/TokenManager/TokenManagerProtocolTests.swift b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerProtocolTests.swift new file mode 100644 index 00000000..4562a54a --- /dev/null +++ b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerProtocolTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing + +@testable import MistKit + +@Suite("Token Manager - Protocol Conformance") +/// Test suite for TokenManager protocol conformance and Sendable compliance +internal struct TokenManagerProtocolTests { + // MARK: - TokenManager Protocol Tests + + /// Tests TokenManager protocol conformance with mock implementation + @Test("TokenManager protocol conformance with mock implementation") + internal func tokenManagerProtocolConformance() async throws { + let mockManager = MockTokenManager() + + // Test protocol methods can be called + let isValid = try await mockManager.validateCredentials() + #expect(isValid == true) + + let authenticator = try await mockManager.currentAuthenticator() + #expect(authenticator != nil) + + // Test computed properties + let hasCredentials = await mockManager.hasCredentials + #expect(hasCredentials == true) + } + + // MARK: - Sendable Compliance Tests + + /// Tests that authenticators and errors are Sendable across async boundaries. + @Test("TokenManager sendable compliance") + internal func sendableCompliance() async throws { + let authenticator = try APITokenAuthenticator(token: TestConstants.apiToken) + let error = TokenManagerError.tokenExpired + + async let task1: String = { + type(of: authenticator).storageKey + }() + async let task2 = error.processError() + + let results = await (task1, task2) + #expect(results.0 == APITokenAuthenticator.storageKey) + #expect(results.1.isEmpty == false) + } +} diff --git a/Tests/MistKitTests/Authentication/TokenManager/TokenManagerTests.swift b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerTests.swift new file mode 100644 index 00000000..f5df6154 --- /dev/null +++ b/Tests/MistKitTests/Authentication/TokenManager/TokenManagerTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing + +@testable import MistKit + +@Suite("Token Manager") +/// Test suite for TokenManager protocol and related types +internal struct TokenManagerTests { + // MARK: - Integration Tests + + /// Tests integration between different TokenManager components + @Test("TokenManager integration test") + internal func tokenManagerIntegration() async throws { + let mockManager = MockTokenManager() + + let isValid = try await mockManager.validateCredentials() + #expect(isValid == true) + + let authenticator = try await mockManager.currentAuthenticator() + let api = try #require(authenticator as? APITokenAuthenticator) + #expect(type(of: api).storageKey == APITokenAuthenticator.storageKey) + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/BasicTests.swift b/Tests/MistKitTests/Authentication/WebAuth/BasicTests.swift deleted file mode 100644 index f7c85c58..00000000 --- a/Tests/MistKitTests/Authentication/WebAuth/BasicTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("Web Auth Token Manager") -internal enum WebAuthTokenManagerTests {} - -extension WebAuthTokenManagerTests { - /// Basic functionality tests for WebAuthTokenManager - @Suite("Basic Tests") - internal struct BasicTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" - // private static let invalidAPIToken = "invalid_token_format" - // private static let shortWebAuthToken = "short" - - // MARK: - Initialization Tests - - /// Tests WebAuthTokenManager initialization with valid tokens - @Test("WebAuthTokenManager initialization with valid tokens") - internal func initializationWithValidTokens() { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - #expect(manager.apiToken == Self.validAPIToken) - #expect(manager.webAuthToken == Self.validWebAuthToken) - } - - /// Tests WebAuthTokenManager initialization with storage - @Test("WebAuthTokenManager initialization with storage") - internal func initializationWithStorage() { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - #expect(manager.apiToken == Self.validAPIToken) - #expect(manager.webAuthToken == Self.validWebAuthToken) - } - - // MARK: - TokenManager Protocol Tests - - /// Tests hasCredentials property with valid tokens - @Test("hasCredentials property with valid tokens") - internal func hasCredentialsWithValidTokens() async { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - let hasCredentials = await manager.hasCredentials - #expect(hasCredentials == true) - } - - /// Tests validateCredentials with valid tokens - @Test("validateCredentials with valid tokens") - internal func validateCredentialsWithValidTokens() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - let isValid = try await manager.validateCredentials() - #expect(isValid == true) - } - - /// Tests getCurrentCredentials with valid tokens - @Test("getCurrentCredentials with valid tokens") - internal func getCurrentCredentialsWithValidTokens() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .webAuthToken(let api, let web) = credentials.method { - #expect(api == Self.validAPIToken) - #expect(web == Self.validWebAuthToken) - } else { - Issue.record("Expected .webAuthToken method") - } - } - } - - // MARK: - Sendable Compliance Tests - - /// Tests that WebAuthTokenManager can be used across async boundaries - @Test("WebAuthTokenManager sendable compliance") - internal func sendableCompliance() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - // Test concurrent access patterns - async let task1 = manager.validateManager() - async let task2 = manager.getCredentialsFromManager() - async let task3 = manager.checkHasCredentials() - - let results = await (task1, task2, task3) - #expect(results.0 == true) - #expect(results.1 != nil) - #expect(results.2 == true) - } - } -} diff --git a/Tests/MistKitTests/Authentication/WebAuth/EdgeCasesTests.swift b/Tests/MistKitTests/Authentication/WebAuth/EdgeCasesTests.swift deleted file mode 100644 index 4da26aaf..00000000 --- a/Tests/MistKitTests/Authentication/WebAuth/EdgeCasesTests.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension WebAuthTokenManagerTests { - /// Edge cases tests for WebAuthTokenManager - @Suite("Edge Cases Tests") - internal struct EdgeCasesTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" - - // MARK: - Concurrent Access Edge Cases - - /// Tests concurrent access to WebAuthTokenManager - @Test("Concurrent access to WebAuthTokenManager") - internal func concurrentAccessToWebAuthTokenManager() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - // Test concurrent access patterns - async let task1 = manager.validateManager() - async let task2 = manager.getCredentialsFromManager() - async let task3 = manager.checkHasCredentials() - async let task4 = manager.validateManager() - async let task5 = manager.getCredentialsFromManager() - - let results = await (task1, task2, task3, task4, task5) - #expect(results.0 == true) - #expect(results.1 != nil) - #expect(results.2 == true) - #expect(results.3 == true) - #expect(results.4 != nil) - } - - /// Tests rapid successive calls to WebAuthTokenManager - @Test("Rapid successive calls to WebAuthTokenManager") - internal func rapidSuccessiveCallsToWebAuthTokenManager() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - // Make rapid successive calls - for _ in 0..<100 { - let hasCredentials = await manager.checkHasCredentials() - #expect(hasCredentials == true) - - let isValid = await manager.validateManager() - #expect(isValid == true) - - let credentials = await manager.getCredentialsFromManager() - #expect(credentials != nil) - } - } - - // MARK: - Memory Management Edge Cases - - /// Tests WebAuthTokenManager with weak references - @Test("WebAuthTokenManager with weak references") - internal func webAuthTokenManagerWithWeakReferences() async throws { - weak var weakManager: WebAuthTokenManager? - - do { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - weakManager = manager - - let hasCredentials = await manager.checkHasCredentials() - #expect(hasCredentials == true) - - let isValid = await manager.validateManager() - #expect(isValid == true) - } - - // Manager should be deallocated - #expect(weakManager == nil) - } - - /// Tests WebAuthTokenManager with storage cleanup - @Test("WebAuthTokenManager with storage cleanup") - internal func webAuthTokenManagerWithStorageCleanup() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - // Store credentials - let credentials = await manager.getCredentialsFromManager() - #expect(credentials != nil) - - // Manager should still work with its own tokens - let hasCredentials = await manager.checkHasCredentials() - #expect(hasCredentials == true) - - let isValid = await manager.validateManager() - #expect(isValid == true) - } - - // MARK: - Error Handling Edge Cases - - /// Tests WebAuthTokenManager with malformed tokens - @Test("WebAuthTokenManager with malformed tokens") - internal func malformedTokens() async throws { - let manager = WebAuthTokenManager( - apiToken: "malformed-api-token", - webAuthToken: "malformed-web-token" - ) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests WebAuthTokenManager with nil-like tokens - @Test("WebAuthTokenManager with nil-like tokens") - internal func webAuthTokenManagerWithNilLikeTokens() async throws { - let manager = WebAuthTokenManager( - apiToken: "null", - webAuthToken: "undefined" - ) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - } -} diff --git a/Tests/MistKitTests/Authentication/WebAuth/ValidationFormatTests.swift b/Tests/MistKitTests/Authentication/WebAuth/ValidationFormatTests.swift deleted file mode 100644 index 7e25d0c4..00000000 --- a/Tests/MistKitTests/Authentication/WebAuth/ValidationFormatTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension WebAuthTokenManagerTests { - /// Token format validation tests for WebAuthTokenManager - @Suite("Validation Format Tests") - internal struct ValidationFormatTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" - private static let invalidAPIToken = "invalid_token_format" - private static let shortWebAuthToken = "short" - private static let emptyAPIToken = "" - private static let emptyWebAuthToken = "" - - // MARK: - Token Format Validation Tests - - /// Tests validation with valid API token format - @Test("Validation with valid API token format") - internal func validAPITokenFormat() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - let isValid = try await manager.validateCredentials() - #expect(isValid == true) - } - - /// Tests validation with invalid API token format - @Test("Validation with invalid API token format") - internal func invalidAPITokenFormat() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.invalidAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests validation with short web auth token - @Test("Validation with short web auth token") - internal func shortWebAuthToken() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.shortWebAuthToken - ) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests validation with empty API token - @Test("Validation with empty API token") - internal func emptyAPIToken() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.emptyAPIToken, - webAuthToken: Self.validWebAuthToken - ) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests validation with empty web auth token - @Test("Validation with empty web auth token") - internal func emptyWebAuthToken() async throws { - let manager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.emptyWebAuthToken - ) - - do { - _ = try await manager.validateCredentials() - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - } -} diff --git a/Tests/MistKitTests/Authentication/WebAuth/ValidationTests.swift b/Tests/MistKitTests/Authentication/WebAuth/ValidationTests.swift deleted file mode 100644 index 058ad1c2..00000000 --- a/Tests/MistKitTests/Authentication/WebAuth/ValidationTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension WebAuthTokenManagerTests { - /// Integration validation tests for WebAuthTokenManager - @Suite("Validation Tests") - internal struct ValidationTests { - // MARK: - Integration Tests - - /// Tests comprehensive validation workflow - @Test("Comprehensive validation workflow") - internal func comprehensiveValidationWorkflow() async throws { - let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - let validWebAuthToken = "user123_web_auth_token_abcdef" - - let manager = WebAuthTokenManager( - apiToken: validAPIToken, - webAuthToken: validWebAuthToken - ) - - // Test hasCredentials - let hasCredentials = await manager.hasCredentials - #expect(hasCredentials == true) - - // Test validateCredentials - let isValid = try await manager.validateCredentials() - #expect(isValid == true) - - // Test getCurrentCredentials - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .webAuthToken(let api, let web) = credentials.method { - #expect(api == validAPIToken) - #expect(web == validWebAuthToken) - } else { - Issue.record("Expected .webAuthToken method") - } - } - } - } -} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenAuthenticatorTests.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenAuthenticatorTests.swift new file mode 100644 index 00000000..abad9e5b --- /dev/null +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenAuthenticatorTests.swift @@ -0,0 +1,136 @@ +// +// WebAuthTokenAuthenticatorTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +/// Per-authenticator tests for `WebAuthTokenAuthenticator`. +@Suite("WebAuthTokenAuthenticator") +internal struct WebAuthTokenAuthenticatorTests { + // MARK: - authenticate(request:body:) + + @Test("authenticate appends ckAPIToken and ckWebAuthToken query items") + internal func appendsBothQueryItems() async throws { + let authenticator = try WebAuthTokenAuthenticator( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + var request = HTTPRequest( + method: .post, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/foo" + ) + var body: HTTPBody? + + try await authenticator.authenticate(request: &request, body: &body) + + let path = try #require(request.path) + #expect(path.contains("ckAPIToken=\(TestConstants.apiToken)")) + #expect(path.contains("ckWebAuthToken=")) + } + + @Test("authenticate character-map-encodes the web auth token") + internal func encodesWebAuthToken() async throws { + let webToken = "abc+def/ghi=jkl0123" + let authenticator = try WebAuthTokenAuthenticator( + apiToken: TestConstants.apiToken, + webAuthToken: webToken + ) + var request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/foo" + ) + var body: HTTPBody? + + try await authenticator.authenticate(request: &request, body: &body) + + let path = try #require(request.path) + // The character map encodes + → %2B, / → %2F, = → %3D — but URLComponents + // additionally percent-encodes the resulting `%` so the query item becomes + // `%252B` etc. We just assert the raw `+`/`/`/`=` characters do not appear + // in the encoded value. + let queryComponents = path.split(separator: "?", maxSplits: 1) + let query = String(queryComponents.last ?? "") + let webItem = query.split(separator: "&").first { $0.hasPrefix("ckWebAuthToken=") } ?? "" + let value = webItem.dropFirst("ckWebAuthToken=".count) + #expect(!value.contains("+")) + #expect(!value.contains("/")) + #expect(!value.contains("=")) + } + + // MARK: - init validation + + @Test("init throws on empty web auth token") + internal func emptyWebTokenThrows() { + do { + _ = try WebAuthTokenAuthenticator( + apiToken: TestConstants.apiToken, + webAuthToken: "" + ) + Issue.record("Expected init to throw") + } catch { + if case .invalidCredentials(.webAuthTokenEmpty) = error { + // Expected + } else { + Issue.record("Unexpected error: \(error)") + } + } + } + + @Test("init throws on web auth token shorter than 10 characters") + internal func shortWebTokenThrows() { + do { + _ = try WebAuthTokenAuthenticator( + apiToken: TestConstants.apiToken, + webAuthToken: "tooshort" + ) + Issue.record("Expected init to throw") + } catch { + if case .invalidCredentials(.webAuthTokenTooShort) = error { + // Expected + } else { + Issue.record("Unexpected error: \(error)") + } + } + } + + // MARK: - serialization round-trip + + @Test("encoded then init(decoding:) round-trips both tokens") + internal func encodingRoundTrip() throws { + let original = try WebAuthTokenAuthenticator( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + let data = try original.encoded() + let restored = try WebAuthTokenAuthenticator(decoding: data) + #expect(restored.apiToken == original.apiToken) + #expect(restored.webAuthToken == original.webAuthToken) + } + + @Test("storageKey is stable") + internal func storageKey() { + #expect(WebAuthTokenAuthenticator.storageKey == "web-auth-token") + } + + @Test("defaultStorageIdentifier uses apiToken prefix") + internal func defaultStorageIdentifier() throws { + let authenticator = try WebAuthTokenAuthenticator( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + #expect(authenticator.defaultStorageIdentifier == "web-\(TestConstants.apiToken.prefix(8))") + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManager+TestHelpers.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManager+TestHelpers.swift index 8e714d09..03f3bfab 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManager+TestHelpers.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManager+TestHelpers.swift @@ -13,10 +13,10 @@ extension WebAuthTokenManager { } } - /// Test helper to get credentials and return them or nil - internal func getCredentialsFromManager() async -> TokenCredentials? { + /// Test helper to get the current authenticator or nil on failure. + internal func authenticatorFromManager() async -> (any Authenticator)? { do { - return try await getCurrentCredentials() + return try await currentAuthenticator() } catch { return nil } diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift new file mode 100644 index 00000000..520ef412 --- /dev/null +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing + +@testable import MistKit + +@Suite("Web Auth Token Manager") +internal enum WebAuthTokenManagerTests {} + +extension WebAuthTokenManagerTests { + /// Basic functionality tests for WebAuthTokenManager + @Suite("Basic") + internal struct Basic { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + // private static let invalidAPIToken = "invalid_token_format" + // private static let shortWebAuthToken = "short" + + // MARK: - Initialization Tests + + /// Tests WebAuthTokenManager initialization with valid tokens + @Test("WebAuthTokenManager initialization with valid tokens") + internal func initializationWithValidTokens() { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + #expect(manager.apiToken == Self.validAPIToken) + #expect(manager.webAuthToken == Self.validWebAuthToken) + } + + /// Tests WebAuthTokenManager initialization with storage + @Test("WebAuthTokenManager initialization with storage") + internal func initializationWithStorage() { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + #expect(manager.apiToken == Self.validAPIToken) + #expect(manager.webAuthToken == Self.validWebAuthToken) + } + + // MARK: - TokenManager Protocol Tests + + /// Tests hasCredentials property with valid tokens + @Test("hasCredentials property with valid tokens") + internal func hasCredentialsWithValidTokens() async { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + let hasCredentials = await manager.hasCredentials + #expect(hasCredentials == true) + } + + /// Tests validateCredentials with valid tokens + @Test("validateCredentials with valid tokens") + internal func validateCredentialsWithValidTokens() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + let isValid = try await manager.validateCredentials() + #expect(isValid == true) + } + + /// Tests currentAuthenticator with valid tokens + @Test("currentAuthenticator with valid tokens") + internal func currentAuthenticatorWithValidTokens() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + let authenticator = try await manager.currentAuthenticator() + let web = try #require(authenticator as? WebAuthTokenAuthenticator) + #expect(web.apiToken == Self.validAPIToken) + #expect(web.webAuthToken == Self.validWebAuthToken) + } + + // MARK: - Sendable Compliance Tests + + /// Tests that WebAuthTokenManager can be used across async boundaries + @Test("WebAuthTokenManager sendable compliance") + internal func sendableCompliance() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + // Test concurrent access patterns + async let task1 = manager.validateManager() + async let task2 = manager.authenticatorFromManager() + async let task3 = manager.checkHasCredentials() + + let results = await (task1, task2, task3) + #expect(results.0 == true) + #expect(results.1 != nil) + #expect(results.2 == true) + } + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+EdgeCases.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+EdgeCases.swift index d629bf4b..3082dbeb 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+EdgeCases.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+EdgeCases.swift @@ -5,7 +5,7 @@ import Testing extension WebAuthTokenManagerTests { /// Edge case validation tests for WebAuthTokenManager - @Suite("Validation Edge Case Tests") + @Suite("Validation Edge Case") internal struct EdgeCases { // MARK: - Edge Case Validation Tests diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift index f62313d9..e8a3d6f0 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift @@ -5,13 +5,13 @@ import Testing extension WebAuthTokenManagerTests { /// Performance edge cases tests for WebAuthTokenManager - @Suite("Edge Cases Performance Tests") + @Suite("Edge Cases Performance") internal struct Performance { // MARK: - Test Data Setup private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken // MARK: - Performance Edge Cases @@ -69,7 +69,7 @@ extension WebAuthTokenManagerTests { } do { - _ = try await manager.getCurrentCredentials() + _ = try await manager.currentAuthenticator() Issue.record("Should have thrown TokenManagerError.invalidCredentials") } catch { switch error { diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationCredentialTests.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationCredentialTests.swift index 5e48e48e..1196ac6b 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationCredentialTests.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationCredentialTests.swift @@ -5,13 +5,13 @@ import Testing extension WebAuthTokenManagerTests { /// Credential validation tests for WebAuthTokenManager - @Suite("Validation Credential Tests") + @Suite("Validation Credential") internal struct Validation { // MARK: - Test Data Setup private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken private static let invalidAPIToken = "invalid_token_format" private static let shortWebAuthToken = "short" @@ -41,37 +41,30 @@ extension WebAuthTokenManagerTests { #expect(hasCredentials == false) } - /// Tests getCurrentCredentials with valid tokens - @Test("getCurrentCredentials with valid tokens") - internal func getCurrentCredentialsValidTokens() async throws { + /// Tests currentAuthenticator with valid tokens + @Test("currentAuthenticator with valid tokens") + internal func currentAuthenticatorValidTokens() async throws { let manager = WebAuthTokenManager( apiToken: Self.validAPIToken, webAuthToken: Self.validWebAuthToken ) - let credentials = try await manager.getCurrentCredentials() - #expect(credentials != nil) - - if let credentials = credentials { - if case .webAuthToken(let api, let web) = credentials.method { - #expect(api == Self.validAPIToken) - #expect(web == Self.validWebAuthToken) - } else { - Issue.record("Expected .webAuthToken method") - } - } + let authenticator = try await manager.currentAuthenticator() + let web = try #require(authenticator as? WebAuthTokenAuthenticator) + #expect(web.apiToken == Self.validAPIToken) + #expect(web.webAuthToken == Self.validWebAuthToken) } - /// Tests getCurrentCredentials with invalid tokens - @Test("getCurrentCredentials with invalid tokens") - internal func getCurrentCredentialsInvalidTokens() async throws { + /// Tests currentAuthenticator with invalid tokens + @Test("currentAuthenticator with invalid tokens") + internal func currentAuthenticatorInvalidTokens() async throws { let manager = WebAuthTokenManager( apiToken: Self.invalidAPIToken, webAuthToken: Self.shortWebAuthToken ) do { - _ = try await manager.getCurrentCredentials() + _ = try await manager.currentAuthenticator() Issue.record("Should have thrown TokenManagerError.invalidCredentials") } catch { switch error { diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationFormat.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationFormat.swift new file mode 100644 index 00000000..5e112d41 --- /dev/null +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationFormat.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing + +@testable import MistKit + +extension WebAuthTokenManagerTests { + /// Token format validation tests for WebAuthTokenManager + @Suite("Validation Format") + internal struct ValidationFormat { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + private static let invalidAPIToken = "invalid_token_format" + private static let shortWebAuthToken = "short" + private static let emptyAPIToken = "" + private static let emptyWebAuthToken = "" + + // MARK: - Token Format Validation Tests + + /// Tests validation with valid API token format + @Test("Validation with valid API token format") + internal func validAPITokenFormat() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + let isValid = try await manager.validateCredentials() + #expect(isValid == true) + } + + /// Tests validation with invalid API token format + @Test("Validation with invalid API token format") + internal func invalidAPITokenFormat() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.invalidAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests validation with short web auth token + @Test("Validation with short web auth token") + internal func shortWebAuthToken() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.shortWebAuthToken + ) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests validation with empty API token + @Test("Validation with empty API token") + internal func emptyAPIToken() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.emptyAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests validation with empty web auth token + @Test("Validation with empty web auth token") + internal func emptyWebAuthToken() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.emptyWebAuthToken + ) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationWorkflow.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationWorkflow.swift new file mode 100644 index 00000000..a3244886 --- /dev/null +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+ValidationWorkflow.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing + +@testable import MistKit + +extension WebAuthTokenManagerTests { + /// Integration validation tests for WebAuthTokenManager + @Suite("Validation Workflow") + internal struct ValidationWorkflow { + // MARK: - Integration Tests + + /// Tests comprehensive validation workflow + @Test("Comprehensive validation workflow") + internal func comprehensiveValidationWorkflow() async throws { + let validAPIToken = + TestConstants.apiToken + let validWebAuthToken = TestConstants.webAuthToken + + let manager = WebAuthTokenManager( + apiToken: validAPIToken, + webAuthToken: validWebAuthToken + ) + + // Test hasCredentials + let hasCredentials = await manager.hasCredentials + #expect(hasCredentials == true) + + // Test validateCredentials + let isValid = try await manager.validateCredentials() + #expect(isValid == true) + + // Test currentAuthenticator + let authenticator = try await manager.currentAuthenticator() + let web = try #require(authenticator as? WebAuthTokenAuthenticator) + #expect(web.apiToken == validAPIToken) + #expect(web.webAuthToken == validWebAuthToken) + } + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+WebAuthEdgeCases.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+WebAuthEdgeCases.swift new file mode 100644 index 00000000..9e0a92fd --- /dev/null +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+WebAuthEdgeCases.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing + +@testable import MistKit + +extension WebAuthTokenManagerTests { + /// Edge cases tests for WebAuthTokenManager + @Suite("Edge Cases") + internal struct WebAuthEdgeCases { + // MARK: - Test Data Setup + + private static let validAPIToken = + TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + + // MARK: - Concurrent Access Edge Cases + + /// Tests concurrent access to WebAuthTokenManager + @Test("Concurrent access to WebAuthTokenManager") + internal func concurrentAccessToWebAuthTokenManager() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + // Test concurrent access patterns + async let task1 = manager.validateManager() + async let task2 = manager.authenticatorFromManager() + async let task3 = manager.checkHasCredentials() + async let task4 = manager.validateManager() + async let task5 = manager.authenticatorFromManager() + + let results = await (task1, task2, task3, task4, task5) + #expect(results.0 == true) + #expect(results.1 != nil) + #expect(results.2 == true) + #expect(results.3 == true) + #expect(results.4 != nil) + } + + /// Tests rapid successive calls to WebAuthTokenManager + @Test("Rapid successive calls to WebAuthTokenManager") + internal func rapidSuccessiveCallsToWebAuthTokenManager() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + // Make rapid successive calls + for _ in 0..<100 { + let hasCredentials = await manager.checkHasCredentials() + #expect(hasCredentials == true) + + let isValid = await manager.validateManager() + #expect(isValid == true) + + let credentials = await manager.authenticatorFromManager() + #expect(credentials != nil) + } + } + + // MARK: - Memory Management Edge Cases + + /// Tests WebAuthTokenManager with weak references + @Test("WebAuthTokenManager with weak references") + internal func webAuthTokenManagerWithWeakReferences() async throws { + weak var weakManager: WebAuthTokenManager? + + do { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + weakManager = manager + + let hasCredentials = await manager.checkHasCredentials() + #expect(hasCredentials == true) + + let isValid = await manager.validateManager() + #expect(isValid == true) + } + + // Manager should be deallocated + #expect(weakManager == nil) + } + + /// Tests WebAuthTokenManager with storage cleanup + @Test("WebAuthTokenManager with storage cleanup") + internal func webAuthTokenManagerWithStorageCleanup() async throws { + let manager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + + // Store credentials + let credentials = await manager.authenticatorFromManager() + #expect(credentials != nil) + + // Manager should still work with its own tokens + let hasCredentials = await manager.checkHasCredentials() + #expect(hasCredentials == true) + + let isValid = await manager.validateManager() + #expect(isValid == true) + } + + // MARK: - Error Handling Edge Cases + + /// Tests WebAuthTokenManager with malformed tokens + @Test("WebAuthTokenManager with malformed tokens") + internal func malformedTokens() async throws { + let manager = WebAuthTokenManager( + apiToken: "malformed-api-token", + webAuthToken: "malformed-web-token" + ) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + + /// Tests WebAuthTokenManager with nil-like tokens + @Test("WebAuthTokenManager with nil-like tokens") + internal func webAuthTokenManagerWithNilLikeTokens() async throws { + let manager = WebAuthTokenManager( + apiToken: "null", + webAuthToken: "undefined" + ) + + do { + _ = try await manager.validateCredentials() + Issue.record("Should have thrown TokenManagerError.invalidCredentials") + } catch { + switch error { + case TokenManagerError.invalidCredentials(_): + // Expected + break + default: + Issue.record("Expected invalidCredentials error, got: \(error)") + } + } + } + } +} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/APIToken/AuthenticationMiddlewareAPITokenTests.swift b/Tests/MistKitTests/AuthenticationMiddleware/APIToken/AuthenticationMiddlewareAPITokenTests.swift deleted file mode 100644 index 593ad221..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/APIToken/AuthenticationMiddlewareAPITokenTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -@Suite("Authentication Middleware - API Token", .enabled(if: Platform.isCryptoAvailable)) -/// API Token authentication tests for AuthenticationMiddleware -internal enum AuthenticationMiddlewareAPITokenTests {} - -extension AuthenticationMiddlewareAPITokenTests { - /// API Token authentication tests - @Suite("API Token Tests", .enabled(if: Platform.isCryptoAvailable)) - internal struct APITokenTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let testOperationID = "test-operation" - - // MARK: - API Token Authentication Tests - - /// Tests intercept with API token authentication - @Test("Intercept request with API token authentication") - internal func interceptWithAPITokenAuthentication() async throws { - let tokenManager = APITokenManager(apiToken: Self.validAPIToken) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - var interceptedRequest: HTTPRequest? - var interceptedBaseURL: URL? - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, _, baseURL in - interceptedRequest = request - interceptedBaseURL = baseURL - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - #expect(interceptedBaseURL == URL.MistKit.cloudKitAPI) - - if let interceptedRequest = interceptedRequest { - #expect(interceptedRequest.path?.contains("ckAPIToken=\(Self.validAPIToken)") == true) - } - } - - /// Tests intercept with API token authentication and existing query parameters - @Test("Intercept request with API token and existing query parameters") - internal func interceptWithAPITokenAuthenticationAndExistingQuery() async throws { - let tokenManager = APITokenManager(apiToken: Self.validAPIToken) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query?existingParam=value" - ) - - var interceptedRequest: HTTPRequest? - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, _, _ in - interceptedRequest = request - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - - if let interceptedRequest = interceptedRequest { - let path = interceptedRequest.path ?? "" - #expect(path.contains("existingParam=value")) - #expect(path.contains("ckAPIToken=\(Self.validAPIToken)")) - } - } - } -} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/AuthenticationMiddlewareTests+nitializationTests.swift b/Tests/MistKitTests/AuthenticationMiddleware/AuthenticationMiddlewareTests+nitializationTests.swift deleted file mode 100644 index 6ca55f0b..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/AuthenticationMiddlewareTests+nitializationTests.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -extension AuthenticationMiddlewareTests { - /// Basic functionality tests for AuthenticationMiddleware - @Suite("Authentication Middleware Initialization") - internal struct InitializationTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" - private static let testOperationID = "test-operation" - - // MARK: - Initialization Tests - - /// Tests AuthenticationMiddleware initialization with APITokenManager - @Test("Authentication Middleware initialization with API token manager") - internal func initializationWithAPITokenManager() { - let tokenManager = APITokenManager(apiToken: Self.validAPIToken) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - // Middleware should be initialized - // Note: tokenManager is not optional, so we just verify it exists - _ = middleware.tokenManager - } - - /// Tests AuthenticationMiddleware initialization with WebAuthTokenManager - @Test("Authentication Middleware initialization with web auth token manager") - internal func initializationWithWebAuthTokenManager() { - let tokenManager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - // Middleware should be initialized - // Note: tokenManager is not optional, so we just verify it exists - _ = middleware.tokenManager - } - - // MARK: - Sendable Compliance Tests - - /// Tests that AuthenticationMiddleware can be used across async boundaries - @Test("Authentication Middleware sendable compliance") - internal func sendableCompliance() async throws { - let tokenManager = APITokenManager(apiToken: Self.validAPIToken) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - // Test concurrent access patterns with separate closures - async let task1 = middleware.interceptWithMiddleware( - request: originalRequest, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID - ) { _, _, _ in - (HTTPResponse(status: .ok), nil) - } - async let task2 = middleware.interceptWithMiddleware( - request: originalRequest, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID - ) { _, _, _ in - (HTTPResponse(status: .ok), nil) - } - async let task3 = middleware.interceptWithMiddleware( - request: originalRequest, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID - ) { _, _, _ in - (HTTPResponse(status: .ok), nil) - } - - let results = await (task1, task2, task3) - #expect(results.0 == true) - #expect(results.1 == true) - #expect(results.2 == true) - } - } -} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/Error/AuthenticationMiddlewareTests+ErrorTests.swift b/Tests/MistKitTests/AuthenticationMiddleware/Error/AuthenticationMiddlewareTests+ErrorTests.swift deleted file mode 100644 index e703898a..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/Error/AuthenticationMiddlewareTests+ErrorTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -extension AuthenticationMiddlewareTests { - /// Error handling tests for AuthenticationMiddleware - @Suite("Error Tests", .enabled(if: Platform.isCryptoAvailable)) - internal struct ErrorTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let testOperationID = "test-operation" - - // MARK: - Token Validation Error Tests - - /// Tests intercept with invalid token manager - @Test("Intercept request with invalid token manager") - internal func interceptWithInvalidTokenManager() async throws { - let mockTokenManager = MockTokenManagerWithoutCredentials() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - _, _, _ in - (HTTPResponse(status: .ok), nil) - } - - do { - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - Issue.record("Should have thrown TokenManagerError.invalidCredentials") - } catch { - switch error { - case TokenManagerError.invalidCredentials(_): - // Expected - break - default: - Issue.record("Expected invalidCredentials error, got: \(error)") - } - } - } - - /// Tests intercept with token manager that throws authentication error - @Test("Intercept request with token manager that throws authentication error") - internal func interceptWithTokenManagerAuthenticationError() async throws { - let mockTokenManager = MockTokenManagerWithAuthenticationError() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - _, _, _ in - (HTTPResponse(status: .ok), nil) - } - - do { - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - Issue.record("Should have thrown TokenManagerError.authenticationFailed") - } catch let error as TokenManagerError { - if case .authenticationFailed = error { - // Expected - } else { - Issue.record("Expected authenticationFailed error, got: \(error)") - } - } - } - - /// Tests intercept with token manager that throws network error - @Test("Intercept request with token manager that throws network error") - internal func interceptWithTokenManagerNetworkError() async throws { - let mockTokenManager = MockTokenManagerWithNetworkError() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - _, _, _ in - (HTTPResponse(status: .ok), nil) - } - - do { - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - Issue.record("Should have thrown TokenManagerError.networkError") - } catch let error as TokenManagerError { - if case .networkError = error { - // Expected - } else { - Issue.record("Expected networkError, got: \(error)") - } - } - } - - /// Tests that errors from next middleware are properly propagated - @Test("Error propagation from next middleware") - internal func errorPropagationFromNextMiddleware() async throws { - let tokenManager = APITokenManager(apiToken: Self.validAPIToken) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - _, _, _ in - throw NSError( - domain: "TestError", - code: 500, - userInfo: [ - NSLocalizedDescriptionKey: "Test error from next middleware" - ] - ) - } - - do { - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - Issue.record("Should have thrown error from next middleware") - } catch let error as NSError { - #expect(error.domain == "TestError") - #expect(error.code == 500) - #expect(error.localizedDescription == "Test error from next middleware") - } - } - } -} - -// MARK: - Mock Token Managers for Error Testing diff --git a/Tests/MistKitTests/AuthenticationMiddleware/Error/MockTokenManagerWithAuthenticationError.swift b/Tests/MistKitTests/AuthenticationMiddleware/Error/MockTokenManagerWithAuthenticationError.swift deleted file mode 100644 index d6022a0b..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/Error/MockTokenManagerWithAuthenticationError.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// MockTokenManagerWithAuthenticationError.swift -// MistKit -// -// Created by Leo Dion on 9/25/25. -// - -@testable import MistKit - -/// Mock TokenManager that throws authentication failed error -internal final class MockTokenManagerWithAuthenticationError: TokenManager { - internal var hasCredentials: Bool { - get async { true } - } - - internal func validateCredentials() async throws(TokenManagerError) -> Bool { - throw TokenManagerError.authenticationFailed(underlying: nil) - } - - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - throw TokenManagerError.authenticationFailed(underlying: nil) - } -} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/Error/MockTokenManagerWithNetworkError.swift b/Tests/MistKitTests/AuthenticationMiddleware/Error/MockTokenManagerWithNetworkError.swift deleted file mode 100644 index 19cf342b..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/Error/MockTokenManagerWithNetworkError.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// MockTokenManagerWithNetworkError.swift -// MistKit -// -// Created by Leo Dion on 9/25/25. -// - -import Foundation - -@testable import MistKit - -/// Mock TokenManager that throws network error -internal final class MockTokenManagerWithNetworkError: TokenManager { - internal var hasCredentials: Bool { - get async { true } - } - - internal func validateCredentials() async throws(TokenManagerError) -> Bool { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "NetworkError", - code: -1_009, - userInfo: [NSLocalizedDescriptionKey: "Network error"] - ) - ) - } - - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "NetworkError", - code: -1_009, - userInfo: [NSLocalizedDescriptionKey: "Network error"] - ) - ) - } -} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/ServerToServer/AuthenticationMiddlewareTests+ServerToServerTests.swift b/Tests/MistKitTests/AuthenticationMiddleware/ServerToServer/AuthenticationMiddlewareTests+ServerToServerTests.swift deleted file mode 100644 index 7d5a599d..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/ServerToServer/AuthenticationMiddlewareTests+ServerToServerTests.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -internal enum AuthenticationMiddlewareTests { -} -extension AuthenticationMiddlewareTests { - /// Server-to-server authentication tests for AuthenticationMiddleware - @Suite("Server-to-Server Tests", .enabled(if: Platform.isCryptoAvailable)) - internal struct ServerToServerTests { - // MARK: - Test Data Setup - - private static let testOperationID = "test-operation" - - // MARK: - Server-to-Server Authentication Tests - - /// Tests intercept with server-to-server authentication - @Test( - "Intercept request with server-to-server authentication", - .enabled(if: Platform.isCryptoAvailable)) - internal func interceptWithServerToServerAuthentication() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("ServerToServerAuthManager is not available on this operating system.") - return - } - let keyID = "test-key-id-12345678" - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - privateKeyCallback: P256.Signing.PrivateKey() - ) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - var interceptedRequest: HTTPRequest? - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, _, _ in - interceptedRequest = request - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - - if let interceptedRequest = interceptedRequest { - // Should have CloudKit-specific headers for server-to-server auth - #expect(interceptedRequest.headerFields[.cloudKitRequestKeyID] != nil) - #expect(interceptedRequest.headerFields[.cloudKitRequestISO8601Date] != nil) - #expect(interceptedRequest.headerFields[.cloudKitRequestSignatureV1] != nil) - } - } - - /// Tests intercept with server-to-server authentication and existing headers - @Test( - "Intercept request with server-to-server authentication and existing headers", - .enabled(if: Platform.isCryptoAvailable)) - internal func interceptWithServerToServerAuthenticationAndExistingHeaders() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("ServerToServerAuthManager is not available on this operating system.") - return - } - let keyID = "test-key-id-12345678" - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - privateKeyCallback: P256.Signing.PrivateKey() - ) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - var originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - originalRequest.headerFields[.userAgent] = "TestAgent/1.0" - originalRequest.headerFields[.contentType] = "application/json" - - var interceptedRequest: HTTPRequest? - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, _, _ in - interceptedRequest = request - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - - if let interceptedRequest = interceptedRequest { - // Should preserve existing headers - #expect(interceptedRequest.headerFields[.userAgent] == "TestAgent/1.0") - #expect(interceptedRequest.headerFields[.contentType] == "application/json") - - // Should add CloudKit-specific headers for server-to-server auth - #expect(interceptedRequest.headerFields[.cloudKitRequestKeyID] != nil) - #expect(interceptedRequest.headerFields[.cloudKitRequestISO8601Date] != nil) - #expect(interceptedRequest.headerFields[.cloudKitRequestSignatureV1] != nil) - } - } - - /// Tests intercept with server-to-server authentication for POST request - @Test( - "Intercept POST request with server-to-server authentication", - .enabled(if: Platform.isCryptoAvailable)) - internal func interceptPOSTWithServerToServerAuthentication() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("ServerToServerAuthManager is not available on this operating system.") - return - } - let keyID = "test-key-id-12345678" - - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - privateKeyCallback: P256.Signing.PrivateKey() - ) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .post, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/modify" - ) - - var interceptedRequest: HTTPRequest? - var interceptedBody: HTTPBody? - - let testBody = HTTPBody("test data") - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, body, _ in - interceptedRequest = request - interceptedBody = body - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: testBody, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - #expect(interceptedBody != nil) - - if let interceptedRequest = interceptedRequest { - // Should have CloudKit-specific headers for server-to-server auth - #expect(interceptedRequest.headerFields[.cloudKitRequestKeyID] != nil) - #expect(interceptedRequest.headerFields[.cloudKitRequestISO8601Date] != nil) - #expect(interceptedRequest.headerFields[.cloudKitRequestSignatureV1] != nil) - } - } - } -} diff --git a/Tests/MistKitTests/AuthenticationMiddleware/WebAuth/AuthenticationMiddlewareWebAuthTests.swift b/Tests/MistKitTests/AuthenticationMiddleware/WebAuth/AuthenticationMiddlewareWebAuthTests.swift deleted file mode 100644 index 35e6cb85..00000000 --- a/Tests/MistKitTests/AuthenticationMiddleware/WebAuth/AuthenticationMiddlewareWebAuthTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -@Suite("Authentication Middleware - Web Auth Token") -/// Web Auth Token authentication tests for AuthenticationMiddleware -internal enum AuthenticationMiddlewareWebAuthTests {} - -extension AuthenticationMiddlewareWebAuthTests { - /// Web Auth Token authentication tests - @Suite("Web Auth Token Tests") - internal struct WebAuthTokenTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let validWebAuthToken = "user123_web_auth_token_abcdef" - private static let testOperationID = "test-operation" - - // MARK: - Web Auth Token Authentication Tests - - /// Tests intercept with web auth token authentication - @Test("Intercept request with web auth token authentication") - internal func interceptWithWebAuthTokenAuthentication() async throws { - let tokenManager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - - var interceptedRequest: HTTPRequest? - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, _, _ in - interceptedRequest = request - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - - if let interceptedRequest = interceptedRequest { - let path = interceptedRequest.path ?? "" - #expect(path.contains("ckAPIToken=\(Self.validAPIToken)")) - #expect(path.contains("ckWebAuthToken=")) - } - } - - /// Tests intercept with web auth token authentication and existing query parameters - @Test("Intercept request with web auth token and existing query parameters") - internal func interceptWithWebAuthTokenAuthenticationAndExistingQuery() async throws { - let tokenManager = WebAuthTokenManager( - apiToken: Self.validAPIToken, - webAuthToken: Self.validWebAuthToken - ) - let middleware = AuthenticationMiddleware(tokenManager: tokenManager) - - let originalRequest = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query?existingParam=value" - ) - - var interceptedRequest: HTTPRequest? - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - request, _, _ in - interceptedRequest = request - return (HTTPResponse(status: .ok), nil) - } - - _ = try await middleware.intercept( - originalRequest, - body: nil as HTTPBody?, - baseURL: URL.MistKit.cloudKitAPI, - operationID: Self.testOperationID, - next: next - ) - - #expect(interceptedRequest != nil) - - if let interceptedRequest = interceptedRequest { - let path = interceptedRequest.path ?? "" - #expect(path.contains("existingParam=value")) - #expect(path.contains("ckAPIToken=\(Self.validAPIToken)")) - #expect(path.contains("ckWebAuthToken=")) - } - } - } -} diff --git a/Tests/MistKitTests/Client/MistKitClientTests+Configuration.swift b/Tests/MistKitTests/Client/MistKitClientTests+Configuration.swift deleted file mode 100644 index bfcfa9e4..00000000 --- a/Tests/MistKitTests/Client/MistKitClientTests+Configuration.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// MistKitClientTests+Configuration.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension MistKitClientTests { - // MARK: - Environment and Database Tests - - @Test( - "MistKitClient supports all environments", - arguments: [ - Environment.development, - Environment.production, - ] - ) - internal func supportsAllEnvironments( - environment: Environment - ) throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: environment, - database: .public, - apiToken: String(repeating: "3", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - @Test( - "MistKitClient supports all databases with API token", - arguments: [ - Database.public, - Database.private, - Database.shared, - ] - ) - internal func supportsAllDatabases(database: Database) throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .development, - database: database, - apiToken: String(repeating: "4", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - // MARK: - Container Identifier Tests - - @Test("MistKitClient accepts various container formats") - internal func acceptsVariousContainerFormats() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let containers = [ - "iCloud.com.example.app", - "iCloud.com.example.MyApp", - "iCloud.com.company.product", - ] - - for container in containers { - let config = MistKitConfiguration( - container: container, - environment: .development, - database: .public, - apiToken: String(repeating: "5", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - } -} diff --git a/Tests/MistKitTests/Client/MistKitClientTests+ServerToServer.swift b/Tests/MistKitTests/Client/MistKitClientTests+ServerToServer.swift deleted file mode 100644 index 3d35da72..00000000 --- a/Tests/MistKitTests/Client/MistKitClientTests+ServerToServer.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// MistKitClientTests+ServerToServer.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension MistKitClientTests { - @Test("MistKitClient rejects ServerToServerAuthManager with shared database") - internal func serverToServerWithSharedDatabase() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - let tokenManager = try ServerToServerAuthManager( - keyID: String(repeating: "0", count: 64), - pemString: privateKeyPEM - ) - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .development, - database: .shared, - apiToken: "" - ) - - let transport = MockTransport() - - do { - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - Issue.record("Expected TokenManagerError for server-to-server with shared database") - } catch let error as TokenManagerError { - if case .invalidCredentials = error { - // Success - } else { - Issue.record("Expected invalidCredentials error, got \(error)") - } - } - } -} diff --git a/Tests/MistKitTests/Client/MistKitClientTests.swift b/Tests/MistKitTests/Client/MistKitClientTests.swift deleted file mode 100644 index 6c19809b..00000000 --- a/Tests/MistKitTests/Client/MistKitClientTests.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// MistKitClientTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("MistKitClient Tests") -internal struct MistKitClientTests { - // MARK: - Configuration-Based Initialization Tests - - @Test("MistKitClient initializes with valid configuration and transport") - internal func initWithConfiguration() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .development, - database: .public, - apiToken: String(repeating: "a", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - @Test("MistKitClient initializes with API token configuration") - internal func initWithAPIToken() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .production, - database: .public, - apiToken: String(repeating: "f", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - // MARK: - Custom TokenManager Initialization Tests - - @Test("MistKitClient initializes with custom TokenManager") - internal func initWithCustomTokenManager() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .development, - database: .public, - apiToken: "" - ) - - let tokenManager = APITokenManager(apiToken: String(repeating: "b", count: 64)) - let transport = MockTransport() - - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - } - - @Test("MistKitClient initializes with individual parameters") - internal func initWithIndividualParameters() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let tokenManager = APITokenManager(apiToken: String(repeating: "c", count: 64)) - let transport = MockTransport() - - _ = try MistKitClient( - container: "iCloud.com.example.app", - environment: .development, - database: .public, - tokenManager: tokenManager, - transport: transport - ) - } - - // MARK: - Server-to-Server Validation Tests - - @Test("MistKitClient allows ServerToServerAuthManager with public database") - internal func serverToServerWithPublicDatabase() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - let tokenManager = try ServerToServerAuthManager( - keyID: String(repeating: "e", count: 64), - pemString: privateKeyPEM - ) - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .development, - database: .public, - apiToken: "" - ) - - let transport = MockTransport() - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - } - - @Test("MistKitClient rejects ServerToServerAuthManager with private database") - internal func serverToServerWithPrivateDatabase() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - let tokenManager = try ServerToServerAuthManager( - keyID: String(repeating: "f", count: 64), - pemString: privateKeyPEM - ) - - let config = MistKitConfiguration( - container: "iCloud.com.example.app", - environment: .development, - database: .private, - apiToken: "" - ) - - let transport = MockTransport() - - do { - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - Issue.record("Expected TokenManagerError for server-to-server with private database") - } catch let error as TokenManagerError { - if case .invalidCredentials = error { - // Success - } else { - Issue.record("Expected invalidCredentials error, got \(error)") - } - } - } -} diff --git a/Tests/MistKitTests/CloudKitService/CloudKitErrorTests.swift b/Tests/MistKitTests/CloudKitService/CloudKitErrorTests.swift new file mode 100644 index 00000000..b3ebc9c6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/CloudKitErrorTests.swift @@ -0,0 +1,65 @@ +// +// CloudKitErrorTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +@Suite("CloudKitError") +internal struct CloudKitErrorTests { + @Test(".missingCredentials with .notConfigured describes as not configured") + internal func missingCredentialsNotConfiguredDescribesAsNotConfigured() throws { + let error = CloudKitError.missingCredentials( + database: .public(.prefers(.webAuth)), + availability: .notConfigured, + reason: "no API token provided" + ) + + let description = try #require(error.errorDescription) + #expect(description.contains("public")) + #expect(description.contains("not configured")) + #expect(!description.contains("required by preference")) + #expect(description.contains("no API token provided")) + } + + @Test(".missingCredentials with .preferenceRequired describes as preference required") + internal func missingCredentialsPreferenceRequiredDescribesAsPreferenceRequired() throws { + let error = CloudKitError.missingCredentials( + database: .public(.requires(.webAuth)), + availability: .preferenceRequired, + reason: "web-auth preference required" + ) + + let description = try #require(error.errorDescription) + #expect(description.contains("public")) + #expect(description.contains("required by preference but not configured")) + #expect(description.contains("web-auth preference required")) + } +} diff --git a/Tests/MistKitTests/CloudKitService/CloudKitServiceTests+Helpers.swift b/Tests/MistKitTests/CloudKitService/CloudKitServiceTests+Helpers.swift new file mode 100644 index 00000000..867fc0f9 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/CloudKitServiceTests+Helpers.swift @@ -0,0 +1,57 @@ +// +// CloudKitServiceTests+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + /// Builds a `CloudKitService` wired to a `MockTransport` driven by `provider`. + /// + /// Shared across the per-operation error-handling sub-suites so each one + /// doesn't need to re-declare the same factory + `testAPIToken` constant. + internal static func makeService( + provider: ResponseProvider, + apiToken: String = TestConstants.apiToken, + containerIdentifier: String = TestConstants.serviceContainerIdentifier + ) throws -> CloudKitService { + let transport = MockTransport(responseProvider: provider) + return try CloudKitService( + containerIdentifier: containerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/CloudKitServiceTests.swift b/Tests/MistKitTests/CloudKitService/CloudKitServiceTests.swift new file mode 100644 index 00000000..a9acd68e --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/CloudKitServiceTests.swift @@ -0,0 +1,30 @@ +// +// CloudKitServiceTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal enum CloudKitServiceTests {} diff --git a/Tests/MistKitTests/CloudKitService/DiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift b/Tests/MistKitTests/CloudKitService/DiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift new file mode 100644 index 00000000..eaff3191 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/DiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift @@ -0,0 +1,121 @@ +// +// CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.DiscoverUserIdentities { + private static let testAPIToken = + TestConstants.apiToken + + internal static func makeSuccessfulService( + identityCount: Int = 1 + ) async throws -> CloudKitService { + let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( + identityCount: identityCount + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } + + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} + +// MARK: - DiscoverUserIdentities Response Builders + +extension ResponseProvider { + internal static func successfulDiscoverUserIdentities( + identityCount: Int = 1 + ) throws -> ResponseProvider { + ResponseProvider( + defaultResponse: try .successfulDiscoverUserIdentitiesResponse(identityCount: identityCount) + ) + } +} + +extension ResponseConfig { + internal static func successfulDiscoverUserIdentitiesResponse( + identityCount: Int = 1 + ) throws -> ResponseConfig { + var users: [[String: Any]] = [] + for index in 0.. CloudKitService { + let responseProvider = try ResponseProvider.successfulFetchCaller( + userRecordName: userRecordName, + firstName: firstName, + lastName: lastName, + emailAddress: emailAddress + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } + + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} + +// MARK: - FetchCaller Response Builders + +extension ResponseProvider { + internal static func successfulFetchCaller( + userRecordName: String = "_user-caller", + firstName: String? = "Test", + lastName: String? = "User", + emailAddress: String? = "caller@example.com" + ) throws -> ResponseProvider { + ResponseProvider( + defaultResponse: try .successfulFetchCallerResponse( + userRecordName: userRecordName, + firstName: firstName, + lastName: lastName, + emailAddress: emailAddress + ) + ) + } +} + +extension ResponseConfig { + internal static func successfulFetchCallerResponse( + userRecordName: String = "_user-caller", + firstName: String? = "Test", + lastName: String? = "User", + emailAddress: String? = "caller@example.com" + ) throws -> ResponseConfig { + var fields: [String] = ["\"userRecordName\": \"\(userRecordName)\""] + if let firstName { + fields.append("\"firstName\": \"\(firstName)\"") + } + if let lastName { + fields.append("\"lastName\": \"\(lastName)\"") + } + if let emailAddress { + fields.append("\"emailAddress\": \"\(emailAddress)\"") + } + + let responseJSON = "{ \(fields.joined(separator: ", ")) }" + + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: responseJSON.data(using: .utf8), + error: nil + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift new file mode 100644 index 00000000..b36b2afc --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift @@ -0,0 +1,80 @@ +// +// CloudKitServiceTests.FetchCaller+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchCaller { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("fetchCaller() returns the caller's user info") + internal func returnsCallerInfo() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchCaller.makeSuccessfulService( + userRecordName: "_user-caller", + firstName: "Test", + lastName: "User", + emailAddress: "caller@example.com" + ) + + let userInfo = try await service.fetchCaller() + + #expect(userInfo.userRecordName == "_user-caller") + #expect(userInfo.firstName == "Test") + #expect(userInfo.lastName == "User") + #expect(userInfo.emailAddress == "caller@example.com") + } + + @Test("fetchCaller() omits optional fields when absent in response") + internal func handlesOmittedOptionalFields() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchCaller.makeSuccessfulService( + userRecordName: "_user-anon", + firstName: nil, + lastName: nil, + emailAddress: nil + ) + + let userInfo = try await service.fetchCaller() + + #expect(userInfo.userRecordName == "_user-anon") + #expect(userInfo.firstName == nil) + #expect(userInfo.lastName == nil) + #expect(userInfo.emailAddress == nil) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift b/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift new file mode 100644 index 00000000..6016f062 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift @@ -0,0 +1,81 @@ +// +// CloudKitServiceTests.FetchCaller+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchCaller { + @Suite("Validation") + internal struct Validation { + @Test("fetchCaller() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchCaller.makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.fetchCaller() + } + } + + @Test("fetchCaller() throws missingCredentials when web-auth is absent") + internal func throwsWhenWebAuthMissing() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // A service with API token only (no webAuthToken) cannot satisfy the + // user-context requirement of fetchCaller. The resolver should throw + // before any HTTP request is dispatched. + let provider = ResponseProvider( + defaultResponse: try .successfulFetchCallerResponse() + ) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials(apiToken: TestConstants.apiToken) + ), + transport: MockTransport(responseProvider: provider) + ) + + await #expect { + _ = try await service.fetchCaller() + } throws: { error in + guard let ckError = error as? CloudKitError, + case .missingCredentials = ckError + else { return false } + return true + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller.swift b/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller.swift new file mode 100644 index 00000000..0a6c9566 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchCaller/CloudKitServiceTests.FetchCaller.swift @@ -0,0 +1,41 @@ +// +// CloudKitServiceTests.FetchCaller.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService FetchCaller Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum FetchCaller {} +} diff --git a/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift new file mode 100644 index 00000000..82cb0ba9 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift @@ -0,0 +1,78 @@ +// +// CloudKitServiceTests.FetchChanges+Concurrent.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchChanges { + @Suite("Concurrent") + internal struct Concurrent { + @Test("fetchAllRecordChanges() is safe under concurrent calls from N tasks") + internal func fetchAllRecordChangesConcurrent() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService( + recordCount: 3, + moreComing: false, + syncToken: "concurrent-token" + ) + let taskCount = 8 + + let results = await withTaskGroup( + of: (records: [RecordInfo], syncToken: String?)?.self + ) { group in + for _ in 0.. CloudKitService { + let responseProvider = try ResponseProvider.successfulFetchChanges( + recordCount: recordCount, + moreComing: moreComing, + syncToken: syncToken + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } + + internal static func makePaginatedService( + pages: [(recordCount: Int, syncToken: String)] + ) async throws -> CloudKitService { + let provider = ResponseProvider( + defaultResponse: try .successfulFetchChangesResponse(moreComing: false) + ) + for (index, page) in pages.enumerated() { + let moreComing = index < pages.count - 1 + await provider.enqueue( + try .successfulFetchChangesResponse( + recordCount: page.recordCount, + moreComing: moreComing, + syncToken: page.syncToken + ), + for: "fetchRecordChanges" + ) + } + let transport = MockTransport(responseProvider: provider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } +} + +// MARK: - FetchChanges Response Builders + +extension ResponseProvider { + internal static func successfulFetchChanges( + recordCount: Int = 2, + moreComing: Bool = false, + syncToken: String = "test-sync-token-abc" + ) throws -> ResponseProvider { + ResponseProvider( + defaultResponse: try .successfulFetchChangesResponse( + recordCount: recordCount, + moreComing: moreComing, + syncToken: syncToken + ) + ) + } +} + +extension ResponseConfig { + internal static func successfulFetchChangesResponse( + recordCount: Int = 2, + moreComing: Bool = false, + syncToken: String = "test-sync-token-abc" + ) throws -> ResponseConfig { + var records: [[String: Any]] = [] + for index in 0.. ResponseConfig + { + var records: [[String: Any]] = [] + for index in 0.. ResponseConfig { + let responseJSON = """ + { + "records": [ + { + "recordName": "deleted-record-0", + "recordType": "Note", + "recordChangeTag": "tag-0", + "deleted": true, + "fields": {} + }, + { + "recordName": "live-record-1", + "recordType": "Note", + "recordChangeTag": "tag-1", + "deleted": false, + "fields": {} + } + ], + "syncToken": "post-deletion-token", + "moreComing": false + } + """ + + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: responseJSON.data(using: .utf8), + error: nil + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift new file mode 100644 index 00000000..c6b58da2 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift @@ -0,0 +1,198 @@ +// +// CloudKitServiceTests.FetchChanges+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchChanges { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("fetchRecordChanges() returns records and sync token") + internal func fetchRecordChangesReturnsRecordsAndToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService( + recordCount: 3, + syncToken: "new-token-123" + ) + + let result = try await service.fetchRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.records.count == 3) + #expect(result.syncToken == "new-token-123") + #expect(result.moreComing == false) + } + + @Test("fetchRecordChanges() reports moreComing flag") + internal func fetchRecordChangesReportsMoreComing() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService( + recordCount: 2, + moreComing: true + ) + + let result = try await service.fetchRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.moreComing == true) + #expect(result.records.count == 2) + } + + @Test("fetchRecordChanges() works without sync token (initial fetch)") + internal func fetchRecordChangesWorksWithoutSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService() + + let result = try await service.fetchRecordChanges( + syncToken: nil, + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.records.isEmpty == false) + #expect(result.syncToken != nil) + } + + @Test("fetchRecordChanges() returns record names and types") + internal func fetchRecordChangesReturnsRecordDetails() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges + .makeSuccessfulService( + recordCount: 2 + ) + + let result = try await service.fetchRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.records[0].recordName == "record-0") + #expect(result.records[0].recordType == "Note") + #expect(result.records[1].recordName == "record-1") + } + + @Test("fetchAllRecordChanges() returns records when no pagination needed") + internal func fetchAllRecordChangesNoPagination() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService( + recordCount: 3, + moreComing: false, + syncToken: "final-token" + ) + + let (records, token) = try await service.fetchAllRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(records.count == 3) + #expect(token == "final-token") + } + + @Test("fetchAllRecordChanges() accumulates records across two pages") + internal func fetchAllRecordChangesMultiPage() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makePaginatedService(pages: [ + (recordCount: 3, syncToken: "token-1"), + (recordCount: 2, syncToken: "token-2"), + ]) + + let (records, token) = try await service.fetchAllRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(records.count == 5) + #expect(token == "token-2") + } + + @Test("fetchAllRecordChanges() uses final syncToken when moreComing transitions to false") + internal func fetchAllRecordChangesFinalSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makePaginatedService(pages: [ + (recordCount: 1, syncToken: "interim-token"), + (recordCount: 1, syncToken: "final-token"), + ]) + + let (_, token) = try await service.fetchAllRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(token == "final-token") + } + + @Test("fetchRecordChanges() surfaces deleted records with deleted flag set") + internal func fetchRecordChangesSurfacesDeletedRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let responseProvider = ResponseProvider( + defaultResponse: .fetchChangesResponseWithDeletedRecord() + ) + let transport = MockTransport(responseProvider: responseProvider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + + let result = try await service.fetchRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.records.count == 2) + let deletedRecord = try #require(result.records.first { $0.recordName == "deleted-record-0" }) + #expect(deletedRecord.deleted == true) + let liveRecord = try #require(result.records.first { $0.recordName == "live-record-1" }) + #expect(liveRecord.deleted == false) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift new file mode 100644 index 00000000..48c7e02f --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift @@ -0,0 +1,144 @@ +// +// CloudKitServiceTests.FetchChanges+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchChanges { + @Suite("Validation") + internal struct Validation { + @Test("fetchRecordChanges() throws 400 for limit of 0") + internal func fetchRecordChangesThrowsForZeroLimit() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService() + + await #expect { + try await service.fetchRecordChanges( + resultsLimit: 0, + database: .public(.prefers(.serverToServer)) + ) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .httpErrorWithRawResponse(let status, _) = ckError + else { return false } + return status == 400 + } + } + + @Test("fetchRecordChanges() throws 400 for limit over 200") + internal func fetchRecordChangesThrowsForLimitOver200() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService() + + await #expect { + try await service.fetchRecordChanges( + resultsLimit: 201, + database: .public(.prefers(.serverToServer)) + ) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .httpErrorWithRawResponse(let status, _) = ckError + else { return false } + return status == 400 + } + } + + @Test("fetchRecordChanges() accepts valid limit values") + internal func fetchRecordChangesAcceptsValidLimits() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService() + + // Minimum valid limit + let result1 = try await service.fetchRecordChanges( + resultsLimit: 1, + database: .public(.prefers(.serverToServer)) + ) + #expect(result1.records.isEmpty == false || result1.syncToken != nil) + + // Maximum valid limit + let result200 = try await service.fetchRecordChanges( + resultsLimit: 200, + database: .public(.prefers(.serverToServer)) + ) + #expect(result200.records.isEmpty == false || result200.syncToken != nil) + } + + @Test("fetchAllRecordChanges() throws invalidResponse for moreComing:true with nil syncToken") + internal func fetchAllRecordChangesThrowsForNilSyncTokenWithMoreComing() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let responseProvider = ResponseProvider( + defaultResponse: .fetchChangesResponseMoreComingNilToken(recordCount: 2) + ) + let transport = MockTransport(responseProvider: responseProvider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + + await #expect(throws: CloudKitError.self) { + _ = try await service.fetchAllRecordChanges(database: .public(.prefers(.serverToServer))) + } + } + + @Test("fetchAllRecordChanges() breaks out when server returns stuck token with no records") + internal func fetchAllRecordChangesEscapesStuckToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makeSuccessfulService( + recordCount: 0, + moreComing: true, + syncToken: "stuck-token" + ) + + let (records, token) = try await service.fetchAllRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(records.isEmpty) + #expect(token == "stuck-token") + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges.SuccessCases+Pagination.swift b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges.SuccessCases+Pagination.swift new file mode 100644 index 00000000..3cee686e --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges.SuccessCases+Pagination.swift @@ -0,0 +1,74 @@ +// +// CloudKitServiceTests.FetchChanges.SuccessCases+Pagination.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchChanges.SuccessCases { + @Test("fetchAllRecordChanges() handles moreComing=true with empty first page") + internal func fetchAllRecordChangesEmptyFirstPage() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makePaginatedService(pages: [ + (recordCount: 0, syncToken: "token-1"), + (recordCount: 3, syncToken: "token-2"), + ]) + + let (records, token) = try await service.fetchAllRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(records.count == 3) + #expect(token == "token-2") + } + + @Test("fetchAllRecordChanges() accumulates records across three pages") + internal func fetchAllRecordChangesThreePage() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchChanges.makePaginatedService(pages: [ + (recordCount: 2, syncToken: "token-1"), + (recordCount: 3, syncToken: "token-2"), + (recordCount: 2, syncToken: "token-3"), + ]) + + let (records, token) = try await service.fetchAllRecordChanges( + database: .public(.prefers(.serverToServer)) + ) + + #expect(records.count == 7) + #expect(token == "token-3") + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges.swift b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges.swift new file mode 100644 index 00000000..201a06db --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchChanges/CloudKitServiceTests.FetchChanges.swift @@ -0,0 +1,38 @@ +// +// CloudKitServiceTests.FetchChanges.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite("CloudKitService FetchChanges Operations", .enabled(if: Platform.isCryptoAvailable)) + internal enum FetchChanges {} +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift new file mode 100644 index 00000000..89cfe880 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift @@ -0,0 +1,114 @@ +// +// CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchZoneChanges { + @Suite("Error Handling") + internal struct ErrorHandling { + @Test("fetchZoneChanges() rejects an invalid sync token with BAD_REQUEST") + internal func fetchZoneChangesRejectsInvalidSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = ResponseProvider( + defaultResponse: .cloudKitError( + statusCode: 400, + serverErrorCode: "BAD_REQUEST", + reason: "Invalid syncToken format" + ) + ) + let service = try CloudKitServiceTests.makeService(provider: provider) + + await #expect { + _ = try await service.fetchZoneChanges(syncToken: "garbage-token") + } throws: { error in + guard let ckError = error as? CloudKitError, + case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason) = ckError + else { return false } + return statusCode == 400 + && serverErrorCode == "BAD_REQUEST" + && reason?.contains("Invalid syncToken") == true + } + } + + @Test("fetchZoneChanges() reports an expired sync token via CloudKitError") + internal func fetchZoneChangesReportsExpiredSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // CloudKit returns 421 with "ZONE_NOT_FOUND" or similar; we use the SYNC_TOKEN_EXPIRED + // server error code documented in the CloudKit Web Services spec. + let provider = ResponseProvider( + defaultResponse: .cloudKitError( + statusCode: 400, + serverErrorCode: "BAD_REQUEST", + reason: "syncToken has expired and the zone must be re-fetched from the beginning" + ) + ) + let service = try CloudKitServiceTests.makeService(provider: provider) + + await #expect { + _ = try await service.fetchZoneChanges(syncToken: "expired-token") + } throws: { error in + guard let ckError = error as? CloudKitError, + case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason) = ckError + else { return false } + return statusCode == 400 + && serverErrorCode == "BAD_REQUEST" + && reason?.contains("expired") == true + } + } + + @Test("fetchZoneChanges() surfaces network connection failure as networkError") + internal func fetchZoneChangesPropagatesConnectionLost() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.makeService( + provider: ResponseProvider.connectionLost() + ) + + await #expect { + _ = try await service.fetchZoneChanges() + } throws: { error in + guard let ckError = error as? CloudKitError, + case .networkError(let urlError) = ckError + else { return false } + return urlError.code == .networkConnectionLost + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift new file mode 100644 index 00000000..38da0d21 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift @@ -0,0 +1,145 @@ +// +// CloudKitServiceTests.FetchZoneChanges+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchZoneChanges { + private static let testAPIToken = + TestConstants.apiToken + + internal static func makeSuccessfulService( + zoneCount: Int = 1, + syncToken: String = "zone-sync-token-abc" + ) async throws -> CloudKitService { + let responseProvider = try ResponseProvider.successfulFetchZoneChanges( + zoneCount: zoneCount, + syncToken: syncToken + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } + + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } +} + +// MARK: - FetchZoneChanges Response Builders + +extension ResponseProvider { + internal static func successfulFetchZoneChanges( + zoneCount: Int = 1, + syncToken: String = "zone-sync-token-abc" + ) throws -> ResponseProvider { + ResponseProvider( + defaultResponse: try .successfulFetchZoneChangesResponse( + zoneCount: zoneCount, + syncToken: syncToken + ) + ) + } +} + +extension ResponseConfig { + internal static func successfulFetchZoneChangesResponse( + zoneCount: Int = 1, + syncToken: String = "zone-sync-token-abc" + ) throws -> ResponseConfig { + var zones: [[String: Any]] = [] + for index in 0.. ResponseConfig { + let responseJSON = """ + { + "zones": [ + { + "zoneID": { + "zoneName": "valid-zone", + "ownerName": "_defaultOwner" + } + }, + {} + ], + "syncToken": "token-with-nil-zone" + } + """ + + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: responseJSON.data(using: .utf8), + error: nil + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift new file mode 100644 index 00000000..07fb20e6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift @@ -0,0 +1,128 @@ +// +// CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchZoneChanges { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("fetchZoneChanges() returns zones and sync token") + internal func fetchZoneChangesReturnsZonesAndToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchZoneChanges.makeSuccessfulService( + zoneCount: 2, + syncToken: "zone-token-xyz" + ) + + let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + + #expect(result.zones.count == 2) + #expect(result.syncToken == "zone-token-xyz") + } + + @Test("fetchZoneChanges() returns zone names") + internal func fetchZoneChangesReturnsZoneNames() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchZoneChanges.makeSuccessfulService( + zoneCount: 1 + ) + + let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + + #expect(result.zones.first?.zoneName == "test-zone-0") + } + + @Test("fetchZoneChanges() returns empty zones array when no changes") + internal func fetchZoneChangesReturnsEmptyArray() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchZoneChanges.makeSuccessfulService( + zoneCount: 0 + ) + + let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + + #expect(result.zones.isEmpty) + #expect(result.syncToken != nil) + } + + @Test("fetchZoneChanges() works with sync token (incremental fetch)") + internal func fetchZoneChangesWorksWithSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchZoneChanges.makeSuccessfulService( + zoneCount: 1, + syncToken: "new-token" + ) + + let result = try await service.fetchZoneChanges( + syncToken: "previous-token", + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.zones.count == 1) + #expect(result.syncToken == "new-token") + } + + @Test("fetchZoneChanges() filters out zones with nil zoneID from server response") + internal func fetchZoneChangesFiltersNilZoneID() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let responseProvider = ResponseProvider( + defaultResponse: .zoneChangesResponseWithNilZoneID() + ) + let transport = MockTransport(responseProvider: responseProvider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + + let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + + #expect(result.zones.count == 1, "Zone with nil zoneID should be filtered out") + #expect(result.zones.first?.zoneName == "valid-zone") + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift new file mode 100644 index 00000000..a7163027 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift @@ -0,0 +1,51 @@ +// +// CloudKitServiceTests.FetchZoneChanges+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchZoneChanges { + @Suite("Validation") + internal struct Validation { + @Test("fetchZoneChanges() throws on authentication error") + internal func fetchZoneChangesThrowsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchZoneChanges.makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.swift new file mode 100644 index 00000000..c952dfba --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.swift @@ -0,0 +1,38 @@ +// +// CloudKitServiceTests.FetchZoneChanges.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite("CloudKitService FetchZoneChanges Operations", .enabled(if: Platform.isCryptoAvailable)) + internal enum FetchZoneChanges {} +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift new file mode 100644 index 00000000..1bdc56d2 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift @@ -0,0 +1,73 @@ +// +// CloudKitServiceTests.LookupUsersByEmail+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByEmail { + private static let testAPIToken = TestConstants.apiToken + + internal static func makeSuccessfulService( + identityCount: Int = 1 + ) async throws -> CloudKitService { + // The endpoint returns the same `DiscoverResponse` shape — reuse the + // fixture builder from the DiscoverUserIdentities test helpers. + let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( + identityCount: identityCount + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } + + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift new file mode 100644 index 00000000..4d03e826 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift @@ -0,0 +1,85 @@ +// +// CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByEmail { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("lookupUsersByEmail() returns a single identity") + internal func returnsSingleIdentity() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeSuccessfulService(identityCount: 1) + + let identities = try await service.lookupUsersByEmail(["user@example.com"]) + + #expect(identities.count == 1) + #expect(identities.first?.userRecordName == "_user-0") + } + + @Test("lookupUsersByEmail() returns multiple identities") + internal func returnsMultipleIdentities() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeSuccessfulService(identityCount: 3) + + let identities = try await service.lookupUsersByEmail([ + "a@example.com", + "b@example.com", + "c@example.com", + ]) + + #expect(identities.count == 3) + } + + @Test("lookupUsersByEmail() returns empty array when no matches") + internal func returnsEmptyArray() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeSuccessfulService(identityCount: 0) + + let identities = try await service.lookupUsersByEmail(["unknown@example.com"]) + + #expect(identities.isEmpty) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift new file mode 100644 index 00000000..aa24cac1 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift @@ -0,0 +1,52 @@ +// +// CloudKitServiceTests.LookupUsersByEmail+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByEmail { + @Suite("Validation") + internal struct Validation { + @Test("lookupUsersByEmail() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.lookupUsersByEmail(["user@example.com"]) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift new file mode 100644 index 00000000..6fa2fcf6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift @@ -0,0 +1,41 @@ +// +// CloudKitServiceTests.LookupUsersByEmail.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService LookupUsersByEmail Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum LookupUsersByEmail {} +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift new file mode 100644 index 00000000..a15589d0 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift @@ -0,0 +1,71 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByRecordName { + private static let testAPIToken = TestConstants.apiToken + + internal static func makeSuccessfulService( + identityCount: Int = 1 + ) async throws -> CloudKitService { + let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( + identityCount: identityCount + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } + + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift new file mode 100644 index 00000000..dd620b66 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift @@ -0,0 +1,83 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByRecordName { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("lookupUsersByRecordName() returns a single identity") + internal func returnsSingleIdentity() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeSuccessfulService(identityCount: 1) + + let identities = try await service.lookupUsersByRecordName(["_user-0"]) + + #expect(identities.count == 1) + #expect(identities.first?.userRecordName == "_user-0") + } + + @Test("lookupUsersByRecordName() returns multiple identities") + internal func returnsMultipleIdentities() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeSuccessfulService(identityCount: 3) + + let identities = try await service.lookupUsersByRecordName([ + "_user-0", "_user-1", "_user-2", + ]) + + #expect(identities.count == 3) + } + + @Test("lookupUsersByRecordName() returns empty array when no matches") + internal func returnsEmptyArray() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeSuccessfulService(identityCount: 0) + + let identities = try await service.lookupUsersByRecordName(["_user-unknown"]) + + #expect(identities.isEmpty) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift new file mode 100644 index 00000000..cfccf5c6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift @@ -0,0 +1,52 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByRecordName { + @Suite("Validation") + internal struct Validation { + @Test("lookupUsersByRecordName() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.lookupUsersByRecordName(["_user-0"]) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift new file mode 100644 index 00000000..8bd53028 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift @@ -0,0 +1,41 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService LookupUsersByRecordName Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum LookupUsersByRecordName {} +} diff --git a/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+ErrorHandling.swift new file mode 100644 index 00000000..970d3bb4 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+ErrorHandling.swift @@ -0,0 +1,78 @@ +// +// CloudKitServiceTests.LookupZones+ErrorHandling.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupZones { + @Suite("Error Handling") + internal struct ErrorHandling { + @Test("lookupZones() surfaces a network connection failure as CloudKitError.networkError") + internal func lookupZonesPropagatesConnectionLost() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.makeService( + provider: ResponseProvider.connectionLost() + ) + let zone = ZoneID(zoneName: "_defaultZone", ownerName: nil) + + await #expect { + _ = try await service.lookupZones(zoneIDs: [zone]) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .networkError(let urlError) = ckError + else { return false } + return urlError.code == .networkConnectionLost + } + } + + @Test("lookupZones() surfaces a request timeout as CloudKitError.networkError(.timedOut)") + internal func lookupZonesPropagatesTimeout() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.makeService(provider: ResponseProvider.timeout()) + let zone = ZoneID(zoneName: "_defaultZone", ownerName: nil) + + await #expect { + _ = try await service.lookupZones(zoneIDs: [zone]) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .networkError(let urlError) = ckError + else { return false } + return urlError.code == .timedOut + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift new file mode 100644 index 00000000..7baf2c40 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift @@ -0,0 +1,92 @@ +// +// CloudKitServiceTests.LookupZones+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupZones { + private static let testAPIToken = + TestConstants.apiToken + + internal static func makeSuccessfulService( + zoneCount: Int = 1 + ) async throws -> CloudKitService { + let responseProvider = try ResponseProvider.successfulLookupZones(zoneCount: zoneCount) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } +} + +// MARK: - LookupZones Response Builders + +extension ResponseProvider { + internal static func successfulLookupZones(zoneCount: Int = 1) throws -> ResponseProvider { + ResponseProvider(defaultResponse: try .successfulLookupZonesResponse(zoneCount: zoneCount)) + } +} + +extension ResponseConfig { + internal static func successfulLookupZonesResponse(zoneCount: Int = 1) throws -> ResponseConfig { + var zones: [[String: Any]] = [] + for index in 0.. CloudKitService { + let responseProvider = try ResponseProvider.successfulModifyZones(zoneCount: zoneCount) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} + +// MARK: - ModifyZones Response Builders + +extension ResponseProvider { + internal static func successfulModifyZones(zoneCount: Int = 1) throws -> ResponseProvider { + ResponseProvider(defaultResponse: try .successfulModifyZonesResponse(zoneCount: zoneCount)) + } +} + +extension ResponseConfig { + internal static func successfulModifyZonesResponse(zoneCount: Int = 1) throws -> ResponseConfig { + var zones: [[String: Any]] = [] + for index in 0.. CloudKitService { + let transport = MockTransport( + responseProvider: .validationError(errorType) + ) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: "test-token")), + transport: transport + ) + } + + /// Create service for successful operations + internal static func makeSuccessfulService() throws -> CloudKitService { + let transport = MockTransport( + responseProvider: .successfulQuery() + ) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: "test-token")), + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+SortConversion.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+SortConversion.swift new file mode 100644 index 00000000..19bfcb7b --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+SortConversion.swift @@ -0,0 +1,82 @@ +// +// CloudKitServiceTests.Query+SortConversion.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Query { + @Suite("Sort Conversion") + internal struct SortConversion { + @Test("QuerySort converts to Components.Schemas format correctly") + internal func querySortConvertsToComponentsFormat() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // Test ascending sort + let ascendingSort = QuerySort.ascending("createdAt") + let componentsAsc = Components.Schemas.Sort(from: ascendingSort) + + #expect(componentsAsc.fieldName == "createdAt") + #expect(componentsAsc.ascending == true) + + // Test descending sort + let descendingSort = QuerySort.descending("modifiedAt") + let componentsDesc = Components.Schemas.Sort(from: descendingSort) + + #expect(componentsDesc.fieldName == "modifiedAt") + #expect(componentsDesc.ascending == false) + } + + @Test("QuerySort handles various field name formats") + internal func querySortHandlesVariousFieldNameFormats() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let fieldNames = [ + "simpleField", + "camelCaseField", + "snake_case_field", + "field123", + "field_with_multiple_underscores", + ] + + for fieldName in fieldNames { + let sort = QuerySort.ascending(fieldName) + let components = Components.Schemas.Sort(from: sort) + + #expect(components.fieldName == fieldName, "Failed for field name: \(fieldName)") + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+Validation.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+Validation.swift new file mode 100644 index 00000000..c8453bd6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+Validation.swift @@ -0,0 +1,142 @@ +// +// CloudKitServiceTests.Query+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Query { + @Suite("Validation") + internal struct Validation { + @Test("queryRecords() validates empty recordType") + internal func queryRecordsValidatesEmptyRecordType() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Query.makeValidationErrorService(.emptyRecordType) + + do { + _ = try await service.queryRecords( + recordType: "", + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected error for empty recordType") + } catch let error as CloudKitError { + // Verify we get the correct validation error + if case .httpErrorWithRawResponse(let statusCode, let response) = error { + #expect(statusCode == 400) + #expect(response.contains("recordType cannot be empty")) + } else { + Issue.record("Expected httpErrorWithRawResponse error") + } + } catch { + Issue.record("Expected CloudKitError, got \(type(of: error))") + } + } + + @Test("queryRecords() validates limit too small", arguments: [-1, 0]) + internal func queryRecordsValidatesLimitTooSmall(limit: Int) async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Query.makeValidationErrorService(.limitTooSmall(limit)) + + do { + _ = try await service.queryRecords( + recordType: "Article", + limit: limit, + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected error for limit \(limit)") + } catch { + if case .httpErrorWithRawResponse(let statusCode, let response) = error { + #expect(statusCode == 400) + #expect(response.contains("limit must be between 1 and 200")) + } else { + Issue.record("Expected httpErrorWithRawResponse error") + } + } + } + + @Test("queryRecords() validates limit too large", arguments: [201, 300, 1_000]) + internal func queryRecordsValidatesLimitTooLarge(limit: Int) async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Query.makeValidationErrorService(.limitTooLarge(limit)) + + do { + _ = try await service.queryRecords( + recordType: "Article", + limit: limit, + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected error for limit \(limit)") + } catch { + if case .httpErrorWithRawResponse(let statusCode, let response) = error { + #expect(statusCode == 400) + #expect(response.contains("limit must be between 1 and 200")) + } else { + Issue.record("Expected httpErrorWithRawResponse error") + } + } + } + + @Test("queryRecords() accepts valid limit range", arguments: [1, 50, 100, 200]) + internal func queryRecordsAcceptsValidLimitRange(limit: Int) async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Query.makeSuccessfulService() + + // This test verifies validation passes - actual API call will fail without real credentials + // but we're testing that validation doesn't throw + do { + _ = try await service.queryRecords( + recordType: "Article", + limit: limit, + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected network error since we don't have real credentials") + } catch { + // We expect a network/auth error, not a validation error + // Validation errors have status code 400 + if case .httpErrorWithRawResponse(let statusCode, _) = error { + #expect(statusCode != 400, "Validation should not fail for limit \(limit)") + } + // Other CloudKit errors are expected (auth, network, etc.) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query.swift new file mode 100644 index 00000000..18e5cd5a --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query.swift @@ -0,0 +1,38 @@ +// +// CloudKitServiceTests.Query.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite("CloudKitService Query Operations", .enabled(if: Platform.isCryptoAvailable)) + internal enum Query {} +} diff --git a/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+ErrorCases.swift b/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+ErrorCases.swift new file mode 100644 index 00000000..34f16333 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+ErrorCases.swift @@ -0,0 +1,72 @@ +// +// CloudKitServiceTests.QueryPagination+ErrorCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.QueryPagination { + @Suite("Error Cases") + internal struct ErrorCases { + @Test("queryAllRecords() throws paginationLimitExceeded carrying collected records") + internal func queryAllRecordsOverflowReturnsAccumulatedRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.QueryPagination.makePaginatedService( + pages: [ + (recordCount: 3, continuationMarker: "marker-1"), + (recordCount: 2, continuationMarker: "marker-2"), + (recordCount: 5, continuationMarker: "marker-3"), + ] + ) + + do { + _ = try await service.queryAllRecords( + recordType: "TestRecord", + maxPages: 2, + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected paginationLimitExceeded to be thrown") + } catch CloudKitError.paginationLimitExceeded(let maxPages, let records) { + #expect(maxPages == 2) + #expect(records.count == 5) + #expect( + records.map(\.recordName) == [ + "record-0", "record-1", "record-2", + "record-0", "record-1", + ]) + } catch { + Issue.record("Expected paginationLimitExceeded, got \(error)") + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift b/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift new file mode 100644 index 00000000..c94ddbe6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift @@ -0,0 +1,116 @@ +// +// CloudKitServiceTests.QueryPagination+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.QueryPagination { + private static let testAPIToken = + TestConstants.apiToken + + internal static func makeSuccessfulService( + recordCount: Int = 2, + continuationMarker: String? = nil + ) throws -> CloudKitService { + let responseProvider = ResponseProvider( + defaultResponse: try .successfulQueryResponse( + recordCount: recordCount, + continuationMarker: continuationMarker + ) + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } + + internal static func makePaginatedService( + pages: [(recordCount: Int, continuationMarker: String?)] + ) async throws -> CloudKitService { + let provider = ResponseProvider( + defaultResponse: try .successfulQueryResponse() + ) + for page in pages { + await provider.enqueue( + try .successfulQueryResponse( + recordCount: page.recordCount, + continuationMarker: page.continuationMarker + ), + for: "queryRecords" + ) + } + let transport = MockTransport(responseProvider: provider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } +} + +// MARK: - Query Pagination Response Builders + +extension ResponseConfig { + internal static func successfulQueryResponse( + recordCount: Int = 0, + continuationMarker: String? = nil + ) throws -> ResponseConfig { + var records: [[String: Any]] = [] + for index in 0.. AssetUploader { + { data, _ in + let response = """ + { + "singleFile": { + "wrappingKey": "test-wrapping-key-abc123", + "fileChecksum": "test-checksum-def456", + "receipt": "test-receipt-token-xyz", + "referenceChecksum": "test-ref-checksum-789", + "size": \(data.count) + } + } + """ + return (200, Data(response.utf8)) + } + } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal static func makeSuccessfulUploadService( + tokenCount: Int = 1 + ) async throws -> CloudKitService { + let responseProvider = ResponseProvider.successfulUpload(tokenCount: tokenCount) + + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } + + /// Create service for validation error testing + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal static func makeUploadValidationErrorService( + _ errorType: UploadValidationErrorType + ) async throws -> CloudKitService { + let responseProvider = ResponseProvider.uploadValidationError(errorType) + + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } + + /// Create service for auth errors + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + transport: transport + ) + } +} + +// MARK: - Upload Response Builders + +extension ResponseProvider { + /// Response provider for successful upload operations + internal static func successfulUpload(tokenCount: Int = 1) -> ResponseProvider { + ResponseProvider(defaultResponse: .successfulUploadResponse(tokenCount: tokenCount)) + } + + /// Response provider for upload validation errors + internal static func uploadValidationError(_ type: UploadValidationErrorType) -> ResponseProvider + { + ResponseProvider(defaultResponse: .uploadValidationError(type)) + } +} + +extension ResponseConfig { + /// Creates a successful asset upload response + /// + /// - Parameter tokenCount: Number of upload tokens to include in response + /// - Returns: ResponseConfig with successful upload response + internal static func successfulUploadResponse(tokenCount: Int = 1) -> ResponseConfig { + var tokens: [[String: Any]] = [] + for index in 0.. ResponseConfig { + let responseJSON = """ + { + "tokens": [ + { + "url": "https://cvws.icloud-content.com/test-upload-url", + "fieldName": "file" + } + ] + } + """ + + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: responseJSON.data(using: .utf8), + error: nil + ) + } + + /// Creates an upload validation error response (400 Bad Request) + /// + /// - Parameter type: The type of upload validation error + /// - Returns: ResponseConfig with appropriate validation error message + internal static func uploadValidationError(_ type: UploadValidationErrorType) -> ResponseConfig { + let reason: String + switch type { + case .emptyData: + reason = "Asset data cannot be empty" + case .oversizedAsset(let size): + reason = "Asset size \(size) bytes exceeds maximum allowed size of 262144000 bytes (250 MB)" + } + + return cloudKitError( + statusCode: 400, + serverErrorCode: "BAD_REQUEST", + reason: reason + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+NetworkErrors.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+NetworkErrors.swift new file mode 100644 index 00000000..f770bf4a --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+NetworkErrors.swift @@ -0,0 +1,121 @@ +// +// CloudKitServiceTests.Upload+NetworkErrors.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Upload { + @Suite("Network Errors") + internal struct NetworkErrors { + @Test("uploadAssets() surfaces a CloudKit-API timeout as networkError(.timedOut)") + internal func uploadAssetsPropagatesAPITimeout() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.makeService(provider: ResponseProvider.timeout()) + let testData = Data(count: 1_024) + + await #expect { + _ = try await service.uploadAssets( + data: testData, + recordType: "Note", + fieldName: "image", + database: .public(.prefers(.serverToServer)) + ) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .networkError(let urlError) = ckError + else { return false } + return urlError.code == .timedOut + } + } + + @Test("uploadAssets() surfaces a CDN network failure thrown by a custom uploader") + internal func uploadAssetsPropagatesCDNNetworkError() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // CloudKit API returns a valid upload token, but the CDN upload throws. + let provider = ResponseProvider.successfulUpload() + let service = try CloudKitServiceTests.makeService(provider: provider) + let testData = Data(count: 1_024) + let throwingUploader: AssetUploader = { _, _ in + throw URLError(.networkConnectionLost) + } + + await #expect { + _ = try await service.uploadAssets( + data: testData, + recordType: "Note", + fieldName: "image", + using: throwingUploader, + database: .public(.prefers(.serverToServer)) + ) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .networkError(let urlError) = ckError + else { return false } + return urlError.code == .networkConnectionLost + } + } + + @Test("uploadAssets() surfaces a CDN 421 Misdirected Request as httpError") + internal func uploadAssetsPropagatesCDN421() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = ResponseProvider.successfulUpload() + let service = try CloudKitServiceTests.makeService(provider: provider) + let testData = Data(count: 1_024) + let misdirectedUploader: AssetUploader = { _, _ in + (statusCode: 421, data: Data("misdirected".utf8)) + } + + await #expect { + _ = try await service.uploadAssets( + data: testData, + recordType: "Note", + fieldName: "image", + using: misdirectedUploader, + database: .public(.prefers(.serverToServer)) + ) + } throws: { error in + guard let ckError = error as? CloudKitError, + case .httpError(let statusCode) = ckError + else { return false } + return statusCode == 421 + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift new file mode 100644 index 00000000..1752a0cd --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift @@ -0,0 +1,165 @@ +// +// CloudKitServiceTests.Upload+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Upload { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("uploadAssets() successfully uploads valid asset") + internal func uploadAssetsSuccessfullyUploadsValidAsset() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeSuccessfulUploadService(tokenCount: 1) + let testData = Data(count: 1_024) // 1 KB of test data + + let result = try await service.uploadAssets( + data: testData, + recordType: "Note", + fieldName: "image", + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.recordName.isEmpty == false, "Result should have a record name") + #expect(result.fieldName == "file", "Result should have the field name from mock response") + #expect(result.asset.receipt != nil, "Asset should have a receipt from CloudKit") + } + + @Test("uploadAssets() parses single token from response") + internal func uploadAssetsParseSingleToken() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeSuccessfulUploadService(tokenCount: 1) + let testData = Data(count: 2_048) + + let result = try await service.uploadAssets( + data: testData, + recordType: "Note", + fieldName: "image", + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.recordName == "test-record-0") + #expect(result.fieldName == "file") + #expect(result.asset.receipt != nil) + } + + @Test("uploadAssets() returns a single token") + internal func uploadAssetsReturnsSingleToken() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeSuccessfulUploadService(tokenCount: 1) + let testData = Data(count: 4_096) + + let result = try await service.uploadAssets( + data: testData, + recordType: "Note", + fieldName: "image", + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + + #expect(result.recordName == "test-record-0") + #expect(result.fieldName == "file") + #expect(result.asset.receipt != nil) + } + + @Test("requestAssetUploadURL() returns token with url, recordName, and fieldName") + internal func requestAssetUploadURLReturnsToken() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeSuccessfulUploadService(tokenCount: 1) + + let token = try await service.requestAssetUploadURL( + recordType: "Note", + fieldName: "image", + database: .public(.prefers(.serverToServer)) + ) + + #expect(token.url != nil) + #expect(token.recordName == "test-record-0") + #expect(token.fieldName == "file") + } + + @Test("uploadAssets() invokes injected AssetUploader closure, not URLSession.shared") + internal func uploadAssetsInvokesInjectedUploader() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeSuccessfulUploadService(tokenCount: 1) + + actor CallTracker { + private(set) var callCount = 0 + func record() { callCount += 1 } + } + let tracker = CallTracker() + + let trackingUploader: AssetUploader = { data, _ in + await tracker.record() + let response = """ + { + "singleFile": { + "wrappingKey": "key", + "fileChecksum": "cs", + "receipt": "rcpt", + "referenceChecksum": "rcs", + "size": \(data.count) + } + } + """ + return (200, Data(response.utf8)) + } + + _ = try await service.uploadAssets( + data: Data(count: 1_024), + recordType: "Note", + fieldName: "image", + using: trackingUploader, + database: .public(.prefers(.serverToServer)) + ) + + let count = await tracker.callCount + #expect(count == 1, "Custom uploader should have been called exactly once") + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+Validation.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+Validation.swift new file mode 100644 index 00000000..f5c1fc63 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+Validation.swift @@ -0,0 +1,157 @@ +// +// CloudKitServiceTests.Upload+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Upload { + @Suite("Validation") + internal struct Validation { + @Test("uploadAssets() validates empty data") + internal func uploadAssetsValidatesEmptyData() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeUploadValidationErrorService( + .emptyData + ) + + do { + _ = try await service.uploadAssets( + data: Data(), + recordType: "Note", + fieldName: "image", + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected error for empty data") + } catch { + if case .httpErrorWithRawResponse(let statusCode, let response) = error { + #expect(statusCode == 400) + #expect(response.contains("Asset data cannot be empty")) + } else { + Issue.record("Expected httpErrorWithRawResponse error") + } + } + } + + @Test("uploadAssets() validates 15 MB size limit", .disabled(if: Platform.isWasm)) + internal func uploadAssetsValidates15MBLimit() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // Create data just over 15 MB (15 * 1024 * 1024 + 1 bytes) + let oversizedData = Data(count: 15_728_641) + let service = try await CloudKitServiceTests.Upload.makeUploadValidationErrorService( + .oversizedAsset(oversizedData.count) + ) + + do { + _ = try await service.uploadAssets( + data: oversizedData, + recordType: "Note", + fieldName: "image", + database: .public(.prefers(.serverToServer)) + ) + Issue.record("Expected error for oversized asset") + } catch { + if case .httpErrorWithRawResponse(let statusCode, let response) = error { + #expect(statusCode == 413) + #expect(response.contains("exceeds maximum")) + } else { + Issue.record("Expected httpErrorWithRawResponse error, got \(error)") + } + } + } + + @Test("uploadAssets() accepts valid data sizes", .disabled(if: Platform.isWasm)) + internal func uploadAssetsAcceptsValidSizes() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.Upload.makeSuccessfulUploadService() + + // Test various valid sizes (CloudKit limit is 15 MB) + let validSizes = [ + 1, // 1 byte + 1_024, // 1 KB + 1_024 * 1_024, // 1 MB + 10 * 1_024 * 1_024, // 10 MB + 15 * 1_024 * 1_024, // Exactly 15 MB (maximum allowed) + ] + + for size in validSizes { + let data = Data(count: size) + do { + let result = try await service.uploadAssets( + data: data, + recordType: "Note", + fieldName: "image", + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + #expect(result.asset.receipt != nil, "Should receive asset with receipt") + } catch { + Issue.record("Valid size \(size) bytes should not throw error: \(error)") + } + } + } + + @Test("uploadAssets() throws invalidResponse when CloudKit returns token with no recordName") + internal func uploadAssetsThrowsWhenRecordNameIsNil() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let responseProvider = ResponseProvider( + defaultResponse: .uploadResponseWithNilRecordName() + ) + let transport = MockTransport(responseProvider: responseProvider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + + await #expect(throws: CloudKitError.self) { + _ = try await service.uploadAssets( + data: Data(count: 1_024), + recordType: "Note", + fieldName: "image", + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload.swift new file mode 100644 index 00000000..4038bd08 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload.swift @@ -0,0 +1,38 @@ +// +// CloudKitServiceTests.Upload.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite("CloudKitService Upload Operations", .enabled(if: Platform.isCryptoAvailable)) + internal enum Upload {} +} diff --git a/Tests/MistKitTests/Core/Configuration/MistKitConfigurationTests.swift b/Tests/MistKitTests/Core/Configuration/MistKitConfigurationTests.swift deleted file mode 100644 index a7a879f8..00000000 --- a/Tests/MistKitTests/Core/Configuration/MistKitConfigurationTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("MistKit Configuration") -/// Tests for MistKitConfiguration functionality -internal struct MistKitConfigurationTests { - /// Tests MistKitConfiguration initialization with required parameters - @Test("MistKitConfiguration initialization with required parameters") - internal func configurationInitialization() { - // Given - let container = "iCloud.com.example.app" - let apiToken = "test-token" - - // When - let configuration = MistKitConfiguration( - container: container, - environment: .development, - apiToken: apiToken - ) - - // Then - #expect(configuration.container == container) - #expect(configuration.environment == .development) - #expect(configuration.database == .private) - #expect(configuration.apiToken == apiToken) - #expect(configuration.webAuthToken == nil) - #expect(configuration.version == "1") - #expect(configuration.serverURL.absoluteString == "https://api.apple-cloudkit.com") - } -} diff --git a/Tests/MistKitTests/Core/CustomFieldValueTests+Encoding.swift b/Tests/MistKitTests/Core/CustomFieldValueTests+Encoding.swift deleted file mode 100644 index 97c3f0a0..00000000 --- a/Tests/MistKitTests/Core/CustomFieldValueTests+Encoding.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension CustomFieldValueTests { - // MARK: - Edge Cases - - @Test("CustomFieldValue init with empty list") - internal func initWithEmptyList() { - let fieldValue = CustomFieldValue( - value: .listValue([]), - type: .list - ) - - #expect(fieldValue.type == .list) - if case .listValue(let values) = fieldValue.value { - #expect(values.isEmpty) - } else { - Issue.record("Expected listValue") - } - } - - @Test("CustomFieldValue init with nil type") - internal func initWithNilType() { - let fieldValue = CustomFieldValue( - value: .stringValue("test"), - type: nil - ) - - #expect(fieldValue.type == nil) - if case .stringValue(let value) = fieldValue.value { - #expect(value == "test") - } else { - Issue.record("Expected stringValue") - } - } - - // MARK: - Encoding/Decoding Tests - - @Test("CustomFieldValue encodes and decodes string correctly") - internal func encodeDecodeString() throws { - let original = CustomFieldValue( - value: .stringValue("test string"), - type: .string - ) - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(CustomFieldValue.self, from: encoded) - - #expect(decoded.type == .string) - if case .stringValue(let value) = decoded.value { - #expect(value == "test string") - } else { - Issue.record("Expected stringValue") - } - } - - @Test("CustomFieldValue encodes and decodes int64 correctly") - internal func encodeDecodeInt64() throws { - let original = CustomFieldValue( - value: .int64Value(123), - type: .int64 - ) - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(CustomFieldValue.self, from: encoded) - - #expect(decoded.type == .int64) - if case .int64Value(let value) = decoded.value { - #expect(value == 123) - } else { - Issue.record("Expected int64Value") - } - } - - @Test("CustomFieldValue encodes and decodes boolean correctly") - internal func encodeDecodeBoolean() throws { - let original = CustomFieldValue( - value: .booleanValue(true), - type: .int64 - ) - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(CustomFieldValue.self, from: encoded) - - #expect(decoded.type == .int64) - // Note: Booleans encode as int64 (0 or 1) - if case .int64Value(let value) = decoded.value { - #expect(value == 1) - } else { - Issue.record("Expected int64Value from boolean encoding") - } - } -} diff --git a/Tests/MistKitTests/Core/CustomFieldValueTests.swift b/Tests/MistKitTests/Core/CustomFieldValueTests.swift deleted file mode 100644 index 14b852e8..00000000 --- a/Tests/MistKitTests/Core/CustomFieldValueTests.swift +++ /dev/null @@ -1,200 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("CustomFieldValue Tests") -internal struct CustomFieldValueTests { - // MARK: - Initialization Tests - - @Test("CustomFieldValue init with string value and type") - internal func initWithStringValue() { - let fieldValue = CustomFieldValue( - value: .stringValue("test"), - type: .string - ) - - #expect(fieldValue.type == .string) - if case .stringValue(let value) = fieldValue.value { - #expect(value == "test") - } else { - Issue.record("Expected stringValue") - } - } - - @Test("CustomFieldValue init with int64 value and type") - internal func initWithInt64Value() { - let fieldValue = CustomFieldValue( - value: .int64Value(42), - type: .int64 - ) - - #expect(fieldValue.type == .int64) - if case .int64Value(let value) = fieldValue.value { - #expect(value == 42) - } else { - Issue.record("Expected int64Value") - } - } - - @Test("CustomFieldValue init with double value and type") - internal func initWithDoubleValue() { - let fieldValue = CustomFieldValue( - value: .doubleValue(3.14), - type: .double - ) - - #expect(fieldValue.type == .double) - if case .doubleValue(let value) = fieldValue.value { - #expect(value == 3.14) - } else { - Issue.record("Expected doubleValue") - } - } - - @Test("CustomFieldValue init with boolean value and type") - internal func initWithBooleanValue() { - let fieldValue = CustomFieldValue( - value: .booleanValue(true), - type: .int64 - ) - - #expect(fieldValue.type == .int64) - if case .booleanValue(let value) = fieldValue.value { - #expect(value == true) - } else { - Issue.record("Expected booleanValue") - } - } - - @Test("CustomFieldValue init with date value and type") - internal func initWithDateValue() { - let timestamp = 1_000_000.0 - let fieldValue = CustomFieldValue( - value: .dateValue(timestamp), - type: .timestamp - ) - - #expect(fieldValue.type == .timestamp) - if case .dateValue(let value) = fieldValue.value { - #expect(value == timestamp) - } else { - Issue.record("Expected dateValue") - } - } - - @Test("CustomFieldValue init with bytes value and type") - internal func initWithBytesValue() { - let fieldValue = CustomFieldValue( - value: .bytesValue("base64data"), - type: .bytes - ) - - #expect(fieldValue.type == .bytes) - if case .bytesValue(let value) = fieldValue.value { - #expect(value == "base64data") - } else { - Issue.record("Expected bytesValue") - } - } - - @Test("CustomFieldValue init with reference value and type") - internal func initWithReferenceValue() { - let reference = Components.Schemas.ReferenceValue( - recordName: "test-record", - action: .DELETE_SELF - ) - let fieldValue = CustomFieldValue( - value: .referenceValue(reference), - type: .reference - ) - - #expect(fieldValue.type == .reference) - if case .referenceValue(let value) = fieldValue.value { - #expect(value.recordName == "test-record") - #expect(value.action == .DELETE_SELF) - } else { - Issue.record("Expected referenceValue") - } - } - - @Test("CustomFieldValue init with location value and type") - internal func initWithLocationValue() { - let location = Components.Schemas.LocationValue( - latitude: 37.7749, - longitude: -122.4194 - ) - let fieldValue = CustomFieldValue( - value: .locationValue(location), - type: .location - ) - - #expect(fieldValue.type == .location) - if case .locationValue(let value) = fieldValue.value { - #expect(value.latitude == 37.7749) - #expect(value.longitude == -122.4194) - } else { - Issue.record("Expected locationValue") - } - } - - @Test("CustomFieldValue init with asset value and type") - internal func initWithAssetValue() { - let asset = Components.Schemas.AssetValue( - fileChecksum: "checksum123", - size: 1_024 - ) - let fieldValue = CustomFieldValue( - value: .assetValue(asset), - type: .asset - ) - - #expect(fieldValue.type == .asset) - if case .assetValue(let value) = fieldValue.value { - #expect(value.fileChecksum == "checksum123") - #expect(value.size == 1_024) - } else { - Issue.record("Expected assetValue") - } - } - - @Test("CustomFieldValue init with asset value and assetid type") - internal func initWithAssetValueAndAssetidType() { - let asset = Components.Schemas.AssetValue( - fileChecksum: "checksum456", - size: 2_048 - ) - let fieldValue = CustomFieldValue( - value: .assetValue(asset), - type: .assetid - ) - - #expect(fieldValue.type == .assetid) - if case .assetValue(let value) = fieldValue.value { - #expect(value.fileChecksum == "checksum456") - #expect(value.size == 2_048) - } else { - Issue.record("Expected assetValue") - } - } - - @Test("CustomFieldValue init with list value and type") - internal func initWithListValue() { - let list: [CustomFieldValue.CustomFieldValuePayload] = [ - .stringValue("one"), - .int64Value(2), - .doubleValue(3.0), - ] - let fieldValue = CustomFieldValue( - value: .listValue(list), - type: .list - ) - - #expect(fieldValue.type == .list) - if case .listValue(let values) = fieldValue.value { - #expect(values.count == 3) - } else { - Issue.record("Expected listValue") - } - } -} diff --git a/Tests/MistKitTests/Core/Database/DatabaseTests.swift b/Tests/MistKitTests/Core/Database/DatabaseTests.swift deleted file mode 100644 index be56e064..00000000 --- a/Tests/MistKitTests/Core/Database/DatabaseTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -/// Test suite for Database enum functionality and behavior validation -@Suite("Database") -internal struct DatabaseTests { - /// Tests Database enum raw values - @Test("Database enum raw values") - internal func databaseRawValues() { - #expect(Database.public.rawValue == "public") - #expect(Database.private.rawValue == "private") - #expect(Database.shared.rawValue == "shared") - } -} diff --git a/Tests/MistKitTests/Core/Environment/EnvironmentTests.swift b/Tests/MistKitTests/Core/Environment/EnvironmentTests.swift deleted file mode 100644 index 78efabe7..00000000 --- a/Tests/MistKitTests/Core/Environment/EnvironmentTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("Environment") -/// Tests for Environment enum functionality -internal struct EnvironmentTests { - /// Tests Environment enum raw values - @Test("Environment enum raw values") - internal func environmentRawValues() { - #expect(Environment.development.rawValue == "development") - #expect(Environment.production.rawValue == "production") - } -} diff --git a/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift b/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift new file mode 100644 index 00000000..ca9ab28a --- /dev/null +++ b/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift @@ -0,0 +1,106 @@ +// +// RecordOperationConversionTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +@Suite("RecordOperation to OpenAPI Conversion") +internal struct RecordOperationConversionTests { + @Test( + "All operation types convert successfully", + arguments: [ + RecordOperation.OperationType.create, + RecordOperation.OperationType.update, + RecordOperation.OperationType.forceUpdate, + RecordOperation.OperationType.replace, + RecordOperation.OperationType.forceReplace, + RecordOperation.OperationType.delete, + RecordOperation.OperationType.forceDelete, + ] + ) + internal func operationTypeConvertsSuccessfully( + operationType: RecordOperation.OperationType + ) throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordOperation conversion is not available on this operating system.") + return + } + let operation = RecordOperation( + operationType: operationType, + recordType: "TestRecord", + recordName: "test-record-name", + fields: ["title": .string("Test")] + ) + + let apiOperation = try Components.Schemas.RecordOperation(from: operation) + #expect(apiOperation.record?.recordType == "TestRecord") + #expect(apiOperation.record?.recordName == "test-record-name") + } + + @Test("Conversion preserves record fields") + internal func conversionPreservesFields() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordOperation conversion is not available on this operating system.") + return + } + let operation = RecordOperation( + operationType: .create, + recordType: "TestRecord", + recordName: "test-name", + fields: [ + "title": .string("Hello"), + "count": .int64(42), + ] + ) + + let apiOperation = try Components.Schemas.RecordOperation(from: operation) + #expect(apiOperation.record?.fields?.additionalProperties.count == 2) + } + + @Test("Conversion preserves recordChangeTag") + internal func conversionPreservesChangeTag() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordOperation conversion is not available on this operating system.") + return + } + let operation = RecordOperation( + operationType: .update, + recordType: "TestRecord", + recordName: "test-name", + fields: ["title": .string("Updated")], + recordChangeTag: "abc123" + ) + + let apiOperation = try Components.Schemas.RecordOperation(from: operation) + #expect(apiOperation.record?.recordChangeTag == "abc123") + } +} diff --git a/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests+Convenience.swift b/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests+Convenience.swift new file mode 100644 index 00000000..adf28f9b --- /dev/null +++ b/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests+Convenience.swift @@ -0,0 +1,65 @@ +// +// RegexPatternsTests+Convenience.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension RegexPatternsTests { + @Suite("Convenience") + internal struct Convenience { + @Test("matches(in:) convenience method works correctly") + internal func convenienceMatchesMethod() { + let token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + let matches = NSRegularExpression.apiTokenRegex.matches(in: token) + + #expect(matches.count == 1) + #expect(matches[0].range.length == 64) + } + + @Test("matches(in:) handles empty string") + internal func convenienceMatchesEmptyString() { + let matches = NSRegularExpression.apiTokenRegex.matches(in: "") + #expect(matches.isEmpty) + } + + @Test("Case sensitivity for hex patterns") + internal func caseSensitivityHex() { + let lowerCase = String(repeating: "a", count: 64) + let upperCase = String(repeating: "A", count: 64) + let mixed = (String(repeating: "a", count: 32) + String(repeating: "A", count: 32)) + + for token in [lowerCase, upperCase, mixed] { + let matches = NSRegularExpression.apiTokenRegex.matches(in: token) + #expect(matches.count == 1, "Should match hex regardless of case") + } + } + } +} diff --git a/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests+Validation.swift b/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests+Validation.swift new file mode 100644 index 00000000..bc31f5b4 --- /dev/null +++ b/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests+Validation.swift @@ -0,0 +1,139 @@ +// +// RegexPatternsTests+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension RegexPatternsTests { + @Suite("Validation") + internal struct Validation { + // MARK: - API Token Validation Tests + + @Test("API token regex validates correct 64-character hex strings") + internal func apiTokenValidHex() { + let validTokens = [ + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + "0000000000000000000000000000000000000000000000000000000000000000", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ] + + for token in validTokens { + let matches = NSRegularExpression.apiTokenRegex.matches(in: token) + #expect(matches.count == 1, "Should match valid API token: \(token)") + } + } + + @Test("API token regex rejects invalid formats") + internal func apiTokenInvalidFormats() { + let invalidTokens = [ + "abc", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567890", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678g", + "abcdef0123456789 abcdef0123456789abcdef0123456789abcdef0123456789", + "", + ] + + for token in invalidTokens { + let matches = NSRegularExpression.apiTokenRegex.matches(in: token) + #expect(matches.isEmpty, "Should not match invalid API token: \(token)") + } + } + + // MARK: - Web Auth Token Validation Tests + + @Test("Web auth token regex validates base64-like strings") + internal func webAuthTokenValidBase64() { + let validTokens = [ + String(repeating: "A", count: 100), + String(repeating: "a", count: 150), + String(repeating: "0", count: 100) + "==", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" + + String(repeating: "A", count: 40), + String(repeating: "Z", count: 200) + "_", + ] + + for token in validTokens { + let matches = NSRegularExpression.webAuthTokenRegex.matches(in: token) + #expect(matches.count == 1, "Should match valid web auth token") + } + } + + @Test("Web auth token regex rejects invalid formats") + internal func webAuthTokenInvalidFormats() { + let invalidTokens = [ + String(repeating: "A", count: 99), + "invalid chars !@#$%", + "", + "abc", + String(repeating: " ", count: 100), + ] + + for token in invalidTokens { + let matches = NSRegularExpression.webAuthTokenRegex.matches(in: token) + #expect(matches.isEmpty, "Should not match invalid web auth token: \(token)") + } + } + + // MARK: - Key ID Validation Tests + + @Test("Key ID regex validates 64-character hex strings") + internal func keyIDValidHex() { + let validKeyIDs = [ + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321", + "0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdefABCD", + ] + + for keyID in validKeyIDs { + let matches = NSRegularExpression.keyIDRegex.matches(in: keyID) + #expect(matches.count == 1, "Should match valid key ID: \(keyID)") + } + } + + @Test("Key ID regex rejects invalid formats") + internal func keyIDInvalidFormats() { + let invalidKeyIDs = [ + String(repeating: "a", count: 63), + String(repeating: "a", count: 65), + "g" + String(repeating: "a", count: 63), + "", + "key-id-with-dashes", + ] + + for keyID in invalidKeyIDs { + let matches = NSRegularExpression.keyIDRegex.matches(in: keyID) + #expect(matches.isEmpty, "Should not match invalid key ID: \(keyID)") + } + } + } +} diff --git a/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests.swift b/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests.swift new file mode 100644 index 00000000..9f7995ae --- /dev/null +++ b/Tests/MistKitTests/Extensions/RegexPatterns/RegexPatternsTests.swift @@ -0,0 +1,33 @@ +// +// RegexPatternsTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("Regex Patterns") +internal enum RegexPatternsTests {} diff --git a/Tests/MistKitTests/Helpers/FilterBuilderTests+ComplexValues.swift b/Tests/MistKitTests/Helpers/FilterBuilderTests+ComplexValues.swift deleted file mode 100644 index 078f56f3..00000000 --- a/Tests/MistKitTests/Helpers/FilterBuilderTests+ComplexValues.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension FilterBuilderTests { - // MARK: - Complex Value Tests - - @Test("FilterBuilder handles boolean values") - internal func booleanValueFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.equals("isActive", FieldValue(booleanValue: true)) - #expect(filter.comparator == .EQUALS) - #expect(filter.fieldName == "isActive") - } - - @Test("FilterBuilder handles reference values") - internal func referenceValueFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let reference = FieldValue.Reference(recordName: "user-123") - let filter = FilterBuilder.equals("owner", .reference(reference)) - #expect(filter.comparator == .EQUALS) - #expect(filter.fieldName == "owner") - } - - @Test("FilterBuilder handles location values") - internal func locationValueFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let location = FieldValue.Location( - latitude: 37.7749, - longitude: -122.4194 - ) - let filter = FilterBuilder.equals("location", .location(location)) - #expect(filter.comparator == .EQUALS) - #expect(filter.fieldName == "location") - } -} diff --git a/Tests/MistKitTests/Helpers/FilterBuilderTests.swift b/Tests/MistKitTests/Helpers/FilterBuilderTests.swift deleted file mode 100644 index 34747d8c..00000000 --- a/Tests/MistKitTests/Helpers/FilterBuilderTests.swift +++ /dev/null @@ -1,200 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("FilterBuilder Tests", .enabled(if: Platform.isCryptoAvailable)) -internal struct FilterBuilderTests { - // MARK: - Equality Filters - - @Test("FilterBuilder creates EQUALS filter") - internal func equalsFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.equals("name", .string("John")) - #expect(filter.comparator == .EQUALS) - #expect(filter.fieldName == "name") - } - - @Test("FilterBuilder creates NOT_EQUALS filter") - internal func notEqualsFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.notEquals("age", .int64(25)) - #expect(filter.comparator == .NOT_EQUALS) - #expect(filter.fieldName == "age") - } - - // MARK: - Comparison Filters - - @Test("FilterBuilder creates LESS_THAN filter") - internal func lessThanFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.lessThan("score", .double(100.0)) - #expect(filter.comparator == .LESS_THAN) - #expect(filter.fieldName == "score") - } - - @Test("FilterBuilder creates LESS_THAN_OR_EQUALS filter") - internal func lessThanOrEqualsFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.lessThanOrEquals("count", .int64(50)) - #expect(filter.comparator == .LESS_THAN_OR_EQUALS) - #expect(filter.fieldName == "count") - } - - @Test("FilterBuilder creates GREATER_THAN filter") - internal func greaterThanFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let date = Date() - let filter = FilterBuilder.greaterThan("createdAt", .date(date)) - #expect(filter.comparator == .GREATER_THAN) - #expect(filter.fieldName == "createdAt") - } - - @Test("FilterBuilder creates GREATER_THAN_OR_EQUALS filter") - internal func greaterThanOrEqualsFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.greaterThanOrEquals("priority", .int64(3)) - #expect(filter.comparator == .GREATER_THAN_OR_EQUALS) - #expect(filter.fieldName == "priority") - } - - // MARK: - String Filters - - @Test("FilterBuilder creates BEGINS_WITH filter") - internal func beginsWithFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.beginsWith("title", "Hello") - #expect(filter.comparator == .BEGINS_WITH) - #expect(filter.fieldName == "title") - } - - @Test("FilterBuilder creates NOT_BEGINS_WITH filter") - internal func notBeginsWithFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.notBeginsWith("email", "spam") - #expect(filter.comparator == .NOT_BEGINS_WITH) - #expect(filter.fieldName == "email") - } - - @Test("FilterBuilder creates CONTAINS_ALL_TOKENS filter") - internal func containsAllTokensFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.containsAllTokens("description", "swift cloudkit") - #expect(filter.comparator == .CONTAINS_ALL_TOKENS) - #expect(filter.fieldName == "description") - } - - // MARK: - List Filters - - @Test("FilterBuilder creates IN filter") - internal func inFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let values: [FieldValue] = [.string("active"), .string("pending")] - let filter = FilterBuilder.in("status", values) - #expect(filter.comparator == .IN) - #expect(filter.fieldName == "status") - #expect(filter.fieldValue?._type == .STRING_LIST) - } - - @Test("FilterBuilder creates NOT_IN filter") - internal func notInFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let values: [FieldValue] = [.string("deleted"), .string("archived")] - let filter = FilterBuilder.notIn("status", values) - #expect(filter.comparator == .NOT_IN) - #expect(filter.fieldName == "status") - #expect(filter.fieldValue?._type == .STRING_LIST) - } - - @Test("FilterBuilder creates IN filter with numbers") - internal func inFilterWithNumbers() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let values: [FieldValue] = [.int64(1), .int64(2), .int64(3)] - let filter = FilterBuilder.in("categoryId", values) - #expect(filter.comparator == .IN) - #expect(filter.fieldName == "categoryId") - #expect(filter.fieldValue?._type == .INT64_LIST) - } - - // MARK: - List Member Filters - - @Test("FilterBuilder creates LIST_CONTAINS filter") - internal func listContainsFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.listContains("tags", .string("important")) - #expect(filter.comparator == .LIST_CONTAINS) - #expect(filter.fieldName == "tags") - } - - @Test("FilterBuilder creates NOT_LIST_CONTAINS filter") - internal func notListContainsFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.notListContains("tags", .string("spam")) - #expect(filter.comparator == .NOT_LIST_CONTAINS) - #expect(filter.fieldName == "tags") - } - - @Test("FilterBuilder creates LIST_MEMBER_BEGINS_WITH filter") - internal func listMemberBeginsWithFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.listMemberBeginsWith("emails", "admin@") - #expect(filter.comparator == .LIST_MEMBER_BEGINS_WITH) - #expect(filter.fieldName == "emails") - } - - @Test("FilterBuilder creates NOT_LIST_MEMBER_BEGINS_WITH filter") - internal func notListMemberBeginsWithFilter() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FilterBuilder is not available on this operating system.") - return - } - let filter = FilterBuilder.notListMemberBeginsWith("domains", "spam") - #expect(filter.comparator == .NOT_LIST_MEMBER_BEGINS_WITH) - #expect(filter.fieldName == "domains") - } -} diff --git a/Tests/MistKitTests/Core/Platform.swift b/Tests/MistKitTests/Helpers/Platform.swift similarity index 100% rename from Tests/MistKitTests/Core/Platform.swift rename to Tests/MistKitTests/Helpers/Platform.swift diff --git a/Tests/MistKitTests/Helpers/SortDescriptorTests.swift b/Tests/MistKitTests/Helpers/SortDescriptorTests.swift deleted file mode 100644 index a0afefcf..00000000 --- a/Tests/MistKitTests/Helpers/SortDescriptorTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("SortDescriptor Tests", .enabled(if: Platform.isCryptoAvailable)) -internal struct SortDescriptorTests { - @Test("SortDescriptor creates ascending sort") - internal func ascendingSort() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("SortDescriptor is not available on this operating system.") - return - } - let sort = SortDescriptor.ascending("name") - #expect(sort.fieldName == "name") - #expect(sort.ascending == true) - } - - @Test("SortDescriptor creates descending sort") - internal func descendingSort() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("SortDescriptor is not available on this operating system.") - return - } - let sort = SortDescriptor.descending("age") - #expect(sort.fieldName == "age") - #expect(sort.ascending == false) - } - - @Test("SortDescriptor creates sort with ascending true") - internal func sortAscendingTrue() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("SortDescriptor is not available on this operating system.") - return - } - let sort = SortDescriptor.sort("score", ascending: true) - #expect(sort.fieldName == "score") - #expect(sort.ascending == true) - } - - @Test("SortDescriptor creates sort with ascending false") - internal func sortAscendingFalse() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("SortDescriptor is not available on this operating system.") - return - } - let sort = SortDescriptor.sort("rating", ascending: false) - #expect(sort.fieldName == "rating") - #expect(sort.ascending == false) - } - - @Test("SortDescriptor defaults to ascending") - internal func sortDefaultAscending() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("SortDescriptor is not available on this operating system.") - return - } - let sort = SortDescriptor.sort("title") - #expect(sort.fieldName == "title") - #expect(sort.ascending == true) - } - - @Test("SortDescriptor handles various field name formats") - internal func variousFieldNameFormats() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("SortDescriptor is not available on this operating system.") - return - } - let sort1 = SortDescriptor.ascending("simple") - #expect(sort1.fieldName == "simple") - - let sort2 = SortDescriptor.ascending("camelCase") - #expect(sort2.fieldName == "camelCase") - - let sort3 = SortDescriptor.ascending("snake_case") - #expect(sort3.fieldName == "snake_case") - - let sort4 = SortDescriptor.ascending("with123Numbers") - #expect(sort4.fieldName == "with123Numbers") - } -} diff --git a/Tests/MistKitTests/Middleware/LoggingMiddlewareTests+Advanced.swift b/Tests/MistKitTests/Middleware/LoggingMiddlewareTests+Advanced.swift deleted file mode 100644 index 6232c325..00000000 --- a/Tests/MistKitTests/Middleware/LoggingMiddlewareTests+Advanced.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// LoggingMiddlewareTests+Advanced.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -extension LoggingMiddlewareTests { - // MARK: - Query Parameter Tests - - @Test("LoggingMiddleware handles query parameters") - internal func handlesQueryParameters() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test?key=value&foo=bar" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let next: - (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(response.status == .ok) - } - - // MARK: - HTTP Method Tests - - @Test( - "LoggingMiddleware handles all HTTP methods", - arguments: [ - HTTPRequest.Method.get, - HTTPRequest.Method.post, - HTTPRequest.Method.put, - HTTPRequest.Method.delete, - HTTPRequest.Method.patch, - HTTPRequest.Method.head, - HTTPRequest.Method.options, - ] - ) - internal func handlesAllHTTPMethods( - method: HTTPRequest.Method - ) async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: method, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let next: - (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(response.status == .ok) - } - - // MARK: - Large Response Body Tests - - @Test("LoggingMiddleware handles large response bodies") - internal func handlesLargeResponseBodies() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let largeData = Data(repeating: 0x41, count: 100_000) - let responseBody = HTTPBody(largeData) - - let next: - (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, responseBody) - } - - let (response, returnedBody) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(response.status == .ok) - #expect(returnedBody != nil) - } - - // MARK: - Concurrent Request Tests - - @Test("LoggingMiddleware handles concurrent requests") - internal func handlesConcurrentRequests() async throws { - let middleware = LoggingMiddleware() - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - try await withThrowingTaskGroup(of: HTTPResponse.Status.self) { group in - for requestIndex in 1...5 { - group.addTask { - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test/\(requestIndex)" - ) - let body: HTTPBody? = nil - - let next: - (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test\(requestIndex)", - next: next - ) - - return response.status - } - } - - for try await status in group { - #expect(status == .ok) - } - } - } -} diff --git a/Tests/MistKitTests/Middleware/LoggingMiddlewareTests+StatusTests.swift b/Tests/MistKitTests/Middleware/LoggingMiddlewareTests+StatusTests.swift deleted file mode 100644 index 7149bc9a..00000000 --- a/Tests/MistKitTests/Middleware/LoggingMiddlewareTests+StatusTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// LoggingMiddlewareTests+StatusTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -extension LoggingMiddlewareTests { - // MARK: - HTTP Status Code Tests - - @Test( - "LoggingMiddleware handles various HTTP status codes", - arguments: [ - HTTPResponse.Status.ok, - HTTPResponse.Status.created, - HTTPResponse.Status.accepted, - HTTPResponse.Status.noContent, - HTTPResponse.Status.badRequest, - HTTPResponse.Status.unauthorized, - HTTPResponse.Status.forbidden, - HTTPResponse.Status.notFound, - HTTPResponse.Status.internalServerError, - ] - ) - internal func handlesVariousStatusCodes(status: HTTPResponse.Status) async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: status) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(response.status == status) - } - - @Test("LoggingMiddleware handles 421 Misdirected Request") - internal func handles421Status() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .init(code: 421, reasonPhrase: "Misdirected Request")) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(response.status.code == 421) - } - - // MARK: - Request Header Tests - - @Test("LoggingMiddleware handles requests with headers") - internal func handlesRequestHeaders() async throws { - let middleware = LoggingMiddleware() - var request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test" - ) - request.headerFields[.authorization] = "Bearer token" - request.headerFields[.contentType] = "application/json" - - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(response.status == .ok) - } -} diff --git a/Tests/MistKitTests/Middleware/LoggingMiddlewareTests.swift b/Tests/MistKitTests/Middleware/LoggingMiddlewareTests.swift deleted file mode 100644 index b68b1750..00000000 --- a/Tests/MistKitTests/Middleware/LoggingMiddlewareTests.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// LoggingMiddlewareTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -@Suite("LoggingMiddleware Tests") -internal struct LoggingMiddlewareTests { - // MARK: - Basic Middleware Tests - - @Test("LoggingMiddleware intercepts and passes through requests") - internal func interceptsAndPassesThrough() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/test/development/public/records/query" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - var nextCalled = false - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - nextCalled = true - let response = HTTPResponse(status: .ok) - return (response, nil) - } - - let (response, responseBody) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - - #expect(nextCalled == true) - #expect(response.status == .ok) - #expect(responseBody == nil) - } - - @Test("LoggingMiddleware handles POST requests") - internal func handlesPostRequests() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .post, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/test/development/public/records/modify" - ) - let bodyData = Data("{\"records\":[]}".utf8) - let body = HTTPBody(bodyData) - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, nil) - } - - let (response, _) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "modify", - next: next - ) - - #expect(response.status == .ok) - } - - @Test("LoggingMiddleware handles response bodies") - internal func handlesResponseBodies() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/test/development/public/records/query" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - let responseBodyData = Data("{\"records\":[]}".utf8) - let responseBody = HTTPBody(responseBodyData) - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - let response = HTTPResponse(status: .ok) - return (response, responseBody) - } - - let (response, returnedBody) = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "query", - next: next - ) - - #expect(response.status == .ok) - - #if DEBUG - // In DEBUG builds, body should be recreated - #expect(returnedBody != nil) - #else - // In RELEASE builds, body should pass through as-is - #expect(returnedBody != nil) - #endif - } - - // MARK: - Error Handling Tests - - @Test("LoggingMiddleware propagates errors from next") - internal func propagatesErrors() async throws { - let middleware = LoggingMiddleware() - let request = HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/test" - ) - let body: HTTPBody? = nil - let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) - - enum TestError: Error { - case simulatedFailure - } - - let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { _, _, _ in - throw TestError.simulatedFailure - } - - do { - _ = try await middleware.intercept( - request, - body: body, - baseURL: baseURL, - operationID: "test", - next: next - ) - Issue.record("Expected error to be propagated") - } catch { - // Expected - error should propagate through middleware - #expect(error is TestError) - } - } -} diff --git a/Tests/MistKitTests/Mocks/ResponseConfig.swift b/Tests/MistKitTests/Mocks/ResponseConfig.swift index 23fc3666..66746228 100644 --- a/Tests/MistKitTests/Mocks/ResponseConfig.swift +++ b/Tests/MistKitTests/Mocks/ResponseConfig.swift @@ -64,34 +64,6 @@ internal struct ResponseConfig: Sendable { error: nil ) } - - /// HTTP error with status code - internal static func httpError(statusCode: Int, message: String? = nil) -> ResponseConfig { - let body: Data? = - if let msg = message { - Data( - """ - { - "error": "\(msg)" - } - """.utf8 - ) - } else { - nil - } - - var headers = HTTPFields() - if body != nil { - headers[.contentType] = "application/json" - } - - return ResponseConfig( - statusCode: statusCode, - headers: headers, - body: body, - error: nil - ) - } } // MARK: - CloudKit Response Builders @@ -151,8 +123,24 @@ extension ResponseConfig { ) } - /// Creates a successful query response - internal static func successfulQuery(records: [String: Any] = [:]) -> ResponseConfig { + /// Creates a transport-layer thrown error (e.g. URLError for timeouts or connection failures). + /// The transport throws this error before any HTTP response is produced. + internal static func networkError(_ error: any Error) -> ResponseConfig { + ResponseConfig(statusCode: 0, error: error) + } + + /// Convenience: simulates a request timeout via URLError(.timedOut). + internal static func timeout() -> ResponseConfig { + .networkError(URLError(.timedOut)) + } + + /// Convenience: simulates a network connection failure via URLError(.networkConnectionLost). + internal static func connectionLost() -> ResponseConfig { + .networkError(URLError(.networkConnectionLost)) + } + + /// Creates a successful query response with an empty records body + internal static func successfulQuery() -> ResponseConfig { let responseJSON = """ { "records": [] diff --git a/Tests/MistKitTests/Mocks/ResponseProvider.swift b/Tests/MistKitTests/Mocks/ResponseProvider.swift index ca002333..db8e7ac9 100644 --- a/Tests/MistKitTests/Mocks/ResponseProvider.swift +++ b/Tests/MistKitTests/Mocks/ResponseProvider.swift @@ -68,27 +68,29 @@ internal actor ResponseProvider { } /// Response provider for successful query operations - internal static func successfulQuery(records: [String: Any] = [:]) -> ResponseProvider { - ResponseProvider(defaultResponse: .successfulQuery(records: records)) + internal static func successfulQuery() -> ResponseProvider { + ResponseProvider(defaultResponse: .successfulQuery()) } - // MARK: - Configuration - - internal func configure(operationID: String, response: ResponseConfig) { - responses[operationID] = response + /// Response provider that simulates a request timeout. + internal static func timeout() -> ResponseProvider { + ResponseProvider(defaultResponse: .timeout()) } - internal func configureDefault(response: ResponseConfig) { - defaultResponse = response + /// Response provider that simulates a connection failure. + internal static func connectionLost() -> ResponseProvider { + ResponseProvider(defaultResponse: .connectionLost()) } + // MARK: - Configuration + internal func enqueue(_ response: ResponseConfig, for operationID: String) { responseQueues[operationID, default: []].append(response) } internal func response( for operationID: String, - request: HTTPRequest + request _: HTTPRequest ) throws -> (HTTPResponse, HTTPBody?) { let config: ResponseConfig if var queue = responseQueues[operationID], !queue.isEmpty { diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithConnectionError.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithConnectionError.swift index 732be3bc..2043cba6 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithConnectionError.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithConnectionError.swift @@ -16,26 +16,10 @@ internal final class MockTokenManagerWithConnectionError: TokenManager { } internal func validateCredentials() async throws(TokenManagerError) -> Bool { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "ConnectionError", - code: -1_004, - userInfo: [ - NSLocalizedDescriptionKey: "Connection failed" - ] - ) - ) + throw TokenManagerError.networkError(.notConnectedToInternet) } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "ConnectionError", - code: -1_004, - userInfo: [ - NSLocalizedDescriptionKey: "Connection failed" - ] - ) - ) + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + throw TokenManagerError.networkError(.notConnectedToInternet) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithIntermittentFailures.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithIntermittentFailures.swift index 6d9a7e18..004df7ed 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithIntermittentFailures.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithIntermittentFailures.swift @@ -31,33 +31,17 @@ internal final class MockTokenManagerWithIntermittentFailures: TokenManager { let count = await counter.increment() // Fail on odd attempts if count % 2 == 1 { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "IntermittentError", - code: -1_001, - userInfo: [ - NSLocalizedDescriptionKey: "Intermittent network failure" - ] - ) - ) + throw TokenManagerError.networkError(.timeout) } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() // Fail on odd attempts if count % 2 == 1 { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "IntermittentError", - code: -1_001, - userInfo: [ - NSLocalizedDescriptionKey: "Intermittent network failure" - ] - ) - ) + throw TokenManagerError.networkError(.timeout) } - return TokenCredentials.apiToken("intermittent-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRateLimiting.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRateLimiting.swift index 946bff90..57f43b8e 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRateLimiting.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRateLimiting.swift @@ -44,13 +44,13 @@ internal final class MockTokenManagerWithRateLimiting: TokenManager { do { try await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds } catch { - throw TokenManagerError.networkError(underlying: error) + throw TokenManagerError.networkError(.other(error)) } } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() // Simulate rate limiting - succeed after multiple attempts if count <= 3 { @@ -58,9 +58,9 @@ internal final class MockTokenManagerWithRateLimiting: TokenManager { do { try await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds } catch { - throw TokenManagerError.networkError(underlying: error) + throw TokenManagerError.networkError(.other(error)) } } - return TokenCredentials.apiToken("rate-limited-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRecovery.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRecovery.swift index d8c48241..57e9d542 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRecovery.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRecovery.swift @@ -29,32 +29,16 @@ internal final class MockTokenManagerWithRecovery: TokenManager { internal func validateCredentials() async throws(TokenManagerError) -> Bool { let count = await counter.increment() if count == 1 { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "NetworkError", - code: -1_009, - userInfo: [ - NSLocalizedDescriptionKey: "Network error" - ] - ) - ) + throw TokenManagerError.networkError(.notConnectedToInternet) } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() if count == 1 { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "NetworkError", - code: -1_009, - userInfo: [ - NSLocalizedDescriptionKey: "Network error" - ] - ) - ) + throw TokenManagerError.networkError(.notConnectedToInternet) } - return TokenCredentials.apiToken("recovered-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefresh.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefresh.swift index 65601507..6f69fc51 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefresh.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefresh.swift @@ -45,13 +45,13 @@ internal final class MockTokenManagerWithRefresh: TokenManager { do { try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds } catch { - throw TokenManagerError.networkError(underlying: error) + throw TokenManagerError.networkError(.other(error)) } } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() // Simulate refresh on first call if count == 1 { @@ -59,9 +59,9 @@ internal final class MockTokenManagerWithRefresh: TokenManager { do { try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds } catch { - throw TokenManagerError.networkError(underlying: error) + throw TokenManagerError.networkError(.other(error)) } } - return TokenCredentials.apiToken("refreshed-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshFailure.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshFailure.swift index 85755096..c134cfd8 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshFailure.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshFailure.swift @@ -39,17 +39,17 @@ internal final class MockTokenManagerWithRefreshFailure: TokenManager { let count = await counter.increment() // Fail on odd calls if count % 2 == 1 { - throw TokenManagerError.authenticationFailed(underlying: nil) + throw TokenManagerError.authenticationFailed(.serverRejected(statusCode: 401, message: nil)) } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() // Fail on odd calls if count % 2 == 1 { - throw TokenManagerError.authenticationFailed(underlying: nil) + throw TokenManagerError.authenticationFailed(.serverRejected(statusCode: 401, message: nil)) } - return TokenCredentials.apiToken("refreshed-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshTimeout.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshTimeout.swift index 16761a3e..7b825438 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshTimeout.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRefreshTimeout.swift @@ -39,22 +39,22 @@ internal final class MockTokenManagerWithRefreshTimeout: TokenManager { do { try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds } catch { - throw TokenManagerError.networkError(underlying: error) + throw TokenManagerError.networkError(.other(error)) } } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() // Simulate timeout on first call if count == 1 { do { try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds } catch { - throw TokenManagerError.networkError(underlying: error) + throw TokenManagerError.networkError(.other(error)) } } - return TokenCredentials.apiToken("refreshed-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRetry.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRetry.swift index 41aa89a4..ed475caa 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRetry.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRetry.swift @@ -30,32 +30,16 @@ internal final class MockTokenManagerWithRetry: TokenManager { internal func validateCredentials() async throws(TokenManagerError) -> Bool { let count = await counter.increment() if count <= 2 { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "NetworkError", - code: -1_009, - userInfo: [ - NSLocalizedDescriptionKey: "Network error" - ] - ) - ) + throw TokenManagerError.networkError(.notConnectedToInternet) } return true } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { let count = await counter.increment() if count <= 2 { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "NetworkError", - code: -1_009, - userInfo: [ - NSLocalizedDescriptionKey: "Network error" - ] - ) - ) + throw TokenManagerError.networkError(.notConnectedToInternet) } - return TokenCredentials.apiToken("retry-token") + return try APITokenAuthenticator(token: TestConstants.apiToken) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithTimeout.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithTimeout.swift index d6331254..86e975b1 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithTimeout.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithTimeout.swift @@ -17,26 +17,10 @@ internal final class MockTokenManagerWithTimeout: TokenManager { } internal func validateCredentials() async throws(TokenManagerError) -> Bool { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "TimeoutError", - code: -1_001, - userInfo: [ - NSLocalizedDescriptionKey: "Timeout" - ] - ) - ) + throw TokenManagerError.networkError(.timeout) } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { - throw TokenManagerError.networkError( - underlying: NSError( - domain: "TimeoutError", - code: -1_001, - userInfo: [ - NSLocalizedDescriptionKey: "Timeout" - ] - ) - ) + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + throw TokenManagerError.networkError(.timeout) } } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithoutCredentials.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithoutCredentials.swift index 9c062787..5a1569a8 100644 --- a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithoutCredentials.swift +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithoutCredentials.swift @@ -16,7 +16,7 @@ internal final class MockTokenManagerWithoutCredentials: TokenManager { false } - internal func getCurrentCredentials() async throws(TokenManagerError) -> TokenCredentials? { + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { nil } } diff --git a/Tests/MistKitTests/Service/AssetUploadTokenTests.swift b/Tests/MistKitTests/Models/AssetUploading/AssetUploadTokenTests.swift similarity index 97% rename from Tests/MistKitTests/Service/AssetUploadTokenTests.swift rename to Tests/MistKitTests/Models/AssetUploading/AssetUploadTokenTests.swift index 4c98b8e4..02a60177 100644 --- a/Tests/MistKitTests/Service/AssetUploadTokenTests.swift +++ b/Tests/MistKitTests/Models/AssetUploading/AssetUploadTokenTests.swift @@ -86,7 +86,7 @@ internal struct AssetUploadTokenTests { @Test("AssetUploadReceipt initializes with all fields") internal func assetUploadReceiptInitializesWithAllFields() { - let asset = FieldValue.Asset( + let asset = Asset( fileChecksum: "abc123", size: 1_024, referenceChecksum: "ref456", @@ -110,7 +110,7 @@ internal struct AssetUploadTokenTests { @Test("AssetUploadReceipt initializes with minimal asset data") internal func assetUploadReceiptInitializesWithMinimalAssetData() { - let asset = FieldValue.Asset(receipt: "minimal-receipt") + let asset = Asset(receipt: "minimal-receipt") let result = AssetUploadReceipt( asset: asset, diff --git a/Tests/MistKitTests/Models/BatchSyncResultTests.swift b/Tests/MistKitTests/Models/BatchSyncResultTests.swift new file mode 100644 index 00000000..13b9607a --- /dev/null +++ b/Tests/MistKitTests/Models/BatchSyncResultTests.swift @@ -0,0 +1,156 @@ +// +// BatchSyncResultTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +@Suite("BatchSyncResult") +internal struct BatchSyncResultTests { + // MARK: - Helpers + + internal static func makeRecord( + name: String, + type: String = "Article" + ) -> RecordInfo { + RecordInfo( + recordName: name, + recordType: type, + recordChangeTag: nil, + fields: [:] + ) + } + + /// Builds an error `RecordInfo` via the same response-decoding path used in + /// production (`RecordInfo.init(from:)` with an empty `RecordResponse`), + /// rather than hardcoding the "Unknown" sentinel string in the test. + /// This matches the pattern in `RecordInfoTests.recordInfoWithUnknownRecord`. + internal static func makeErrorRecord() -> RecordInfo { + RecordInfo(from: Components.Schemas.RecordResponse()) + } + + // MARK: - Tests + + @Test("partitions records into created, updated, failed, and unclassified") + internal func partitionsRecordsIntoAllFourCategories() { + let classification = OperationClassification( + creates: ["new-1", "new-2"], + updates: ["existing-1"] + ) + let records: [RecordInfo] = [ + Self.makeRecord(name: "new-1"), + Self.makeRecord(name: "existing-1"), + Self.makeRecord(name: "new-2"), + Self.makeRecord(name: "server-assigned-name"), + Self.makeErrorRecord(), + ] + + let result = BatchSyncResult(records: records, classification: classification) + + #expect(result.createdCount == 2) + #expect(result.updatedCount == 1) + #expect(result.failedCount == 1) + #expect(result.unclassifiedCount == 1) + #expect(result.created.map(\.recordName).sorted() == ["new-1", "new-2"]) + #expect(result.updated.map(\.recordName) == ["existing-1"]) + #expect(result.unclassified.map(\.recordName) == ["server-assigned-name"]) + } + + @Test("counts add up across all categories") + internal func countsAddUpAcrossCategories() { + let classification = OperationClassification( + creates: ["a"], + updates: ["b"] + ) + let records: [RecordInfo] = [ + Self.makeRecord(name: "a"), + Self.makeRecord(name: "b"), + Self.makeRecord(name: "c"), + Self.makeErrorRecord(), + ] + + let result = BatchSyncResult(records: records, classification: classification) + + #expect(result.totalCount == 4) + #expect(result.succeededCount == 3) + #expect( + result.totalCount == result.createdCount + result.updatedCount + + result.failedCount + result.unclassifiedCount) + } + + @Test("treats error records as failures regardless of classification") + internal func treatsErrorRecordsAsFailures() { + // Build a classification that *would* claim the error record as a create + // by reading its actual recordName, then verify the failure check wins. + let errorRecord = Self.makeErrorRecord() + let classification = OperationClassification( + creates: [errorRecord.recordName], + updates: [] + ) + + let result = BatchSyncResult( + records: [errorRecord], + classification: classification + ) + + #expect(result.failedCount == 1) + #expect(result.createdCount == 0) + } + + @Test("returns empty buckets for empty inputs") + internal func returnsEmptyBucketsForEmptyInputs() { + let classification = OperationClassification(creates: [], updates: []) + let result = BatchSyncResult(records: [], classification: classification) + + #expect(result.totalCount == 0) + #expect(result.succeededCount == 0) + #expect(result.created.isEmpty) + #expect(result.updated.isEmpty) + #expect(result.failed.isEmpty) + #expect(result.unclassified.isEmpty) + } + + @Test("manual init exposes the supplied arrays directly") + internal func manualInitExposesSuppliedArrays() { + let result = BatchSyncResult( + created: [Self.makeRecord(name: "a")], + updated: [Self.makeRecord(name: "b"), Self.makeRecord(name: "c")], + failed: [Self.makeErrorRecord()] + ) + + #expect(result.createdCount == 1) + #expect(result.updatedCount == 2) + #expect(result.failedCount == 1) + #expect(result.unclassifiedCount == 0) + #expect(result.totalCount == 4) + #expect(result.succeededCount == 3) + } +} diff --git a/Tests/MistKitTests/Models/DatabaseTests.swift b/Tests/MistKitTests/Models/DatabaseTests.swift new file mode 100644 index 00000000..679290f5 --- /dev/null +++ b/Tests/MistKitTests/Models/DatabaseTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing + +@testable import MistKit + +/// Test suite for Database enum functionality and behavior validation +@Suite("Database") +internal struct DatabaseTests { + /// Tests that each Database scope produces the expected URL path segment. + @Test("Database pathSegment values") + internal func databasePathSegments() { + #expect(Database.public(.prefers(.serverToServer)).pathSegment == "public") + #expect(Database.public(.requires(.webAuth)).pathSegment == "public") + #expect(Database.private.pathSegment == "private") + #expect(Database.shared.pathSegment == "shared") + } +} diff --git a/Tests/MistKitTests/Models/EnvironmentTests.swift b/Tests/MistKitTests/Models/EnvironmentTests.swift new file mode 100644 index 00000000..6973cb57 --- /dev/null +++ b/Tests/MistKitTests/Models/EnvironmentTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing + +@testable import MistKit + +@Suite("Environment") +/// Tests for Environment enum functionality +internal struct EnvironmentTests { + /// Tests Environment enum raw values + @Test("Environment enum raw values") + internal func environmentRawValues() { + #expect(Environment.development.rawValue == "development") + #expect(Environment.production.rawValue == "production") + } + + @Test( + "init?(caseInsensitive:) matches .production", + arguments: ["production", "Production", "PRODUCTION", "PrOdUcTiOn"] + ) + internal func caseInsensitiveProduction(raw: String) { + #expect(Environment(caseInsensitive: raw) == .production) + } + + @Test( + "init?(caseInsensitive:) matches .development", + arguments: ["development", "Development", "DEVELOPMENT", "DeVeLoPmEnT"] + ) + internal func caseInsensitiveDevelopment(raw: String) { + #expect(Environment(caseInsensitive: raw) == .development) + } + + @Test( + "init?(caseInsensitive:) returns nil for non-canonical values", + arguments: ["staging", "prod", "dev", "test", "qa", " production ", ""] + ) + internal func caseInsensitiveRejectsInvalidValues(raw: String) { + #expect(Environment(caseInsensitive: raw) == nil) + } +} diff --git a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+BasicTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift similarity index 99% rename from Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+BasicTypes.swift rename to Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift index 02840029..d8007f2f 100644 --- a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+BasicTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+ComplexTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift similarity index 92% rename from Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+ComplexTypes.swift rename to Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift index 1546d60b..d58d3d92 100644 --- a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+ComplexTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit @@ -12,7 +13,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let location = FieldValue.Location( + let location = Location( latitude: 37.7749, longitude: -122.4194, horizontalAccuracy: 10.0, @@ -45,7 +46,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let location = FieldValue.Location(latitude: 0.0, longitude: 0.0) + let location = Location(latitude: 0.0, longitude: 0.0) let fieldValue = FieldValue.location(location) let components = Components.Schemas.FieldValueRequest(from: fieldValue) @@ -69,7 +70,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let reference = FieldValue.Reference(recordName: "test-record-123") + let reference = Reference(recordName: "test-record-123") let fieldValue = FieldValue.reference(reference) let components = Components.Schemas.FieldValueRequest(from: fieldValue) @@ -87,7 +88,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let reference = FieldValue.Reference(recordName: "test-record-456", action: .deleteSelf) + let reference = Reference(recordName: "test-record-456", action: .deleteSelf) let fieldValue = FieldValue.reference(reference) let components = Components.Schemas.FieldValueRequest(from: fieldValue) @@ -105,8 +106,8 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let reference = FieldValue.Reference( - recordName: "test-record-789", action: FieldValue.Reference.Action.none + let reference = Reference( + recordName: "test-record-789", action: Reference.Action.none ) let fieldValue = FieldValue.reference(reference) let components = Components.Schemas.FieldValueRequest(from: fieldValue) @@ -125,7 +126,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let asset = FieldValue.Asset( + let asset = Asset( fileChecksum: "abc123", size: 1_024, referenceChecksum: "def456", @@ -154,7 +155,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let asset = FieldValue.Asset() + let asset = Asset() let fieldValue = FieldValue.asset(asset) let components = Components.Schemas.FieldValueRequest(from: fieldValue) diff --git a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+EdgeCases.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift similarity index 98% rename from Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+EdgeCases.swift rename to Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift index 92d383ca..e454294a 100644 --- a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+EdgeCases.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+Lists.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift similarity index 99% rename from Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+Lists.swift rename to Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift index ee712fdf..9fa87df7 100644 --- a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests+Lists.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests.swift similarity index 100% rename from Tests/MistKitTests/Core/FieldValue/FieldValueConversionTests.swift rename to Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests.swift diff --git a/Tests/MistKitTests/Core/FieldValue/FieldValueTests.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift similarity index 95% rename from Tests/MistKitTests/Core/FieldValue/FieldValueTests.swift rename to Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift index 99a2e3a6..8b90abc4 100644 --- a/Tests/MistKitTests/Core/FieldValue/FieldValueTests.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift @@ -48,7 +48,7 @@ internal struct FieldValueTests { /// Tests FieldValue location type creation and equality @Test("FieldValue location type creation and equality") internal func fieldValueLocation() { - let location = FieldValue.Location( + let location = Location( latitude: 37.7749, longitude: -122.4194, horizontalAccuracy: 10.0 @@ -60,7 +60,7 @@ internal struct FieldValueTests { /// Tests FieldValue reference type creation and equality @Test("FieldValue reference type creation and equality") internal func fieldValueReference() { - let reference = FieldValue.Reference(recordName: "test-record") + let reference = Reference(recordName: "test-record") let value = FieldValue.reference(reference) #expect(value == .reference(reference)) } @@ -68,7 +68,7 @@ internal struct FieldValueTests { /// Tests FieldValue asset type creation and equality @Test("FieldValue asset type creation and equality") internal func fieldValueAsset() { - let asset = FieldValue.Asset( + let asset = Asset( fileChecksum: "abc123", size: 1_024, downloadURL: "https://example.com/file" diff --git a/Tests/MistKitTests/Models/OperationClassificationTests.swift b/Tests/MistKitTests/Models/OperationClassificationTests.swift new file mode 100644 index 00000000..4d0e6aec --- /dev/null +++ b/Tests/MistKitTests/Models/OperationClassificationTests.swift @@ -0,0 +1,137 @@ +// +// OperationClassificationTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +@Suite("OperationClassification") +internal struct OperationClassificationTests { + @Test("partitions proposed names against existing names") + internal func partitionsProposedNamesAgainstExistingNames() { + let classification = OperationClassification( + proposedRecordNames: ["a", "b", "c", "d"], + existingRecordNames: ["b", "d", "e"] + ) + + #expect(classification.creates == ["a", "c"]) + #expect(classification.updates == ["b", "d"]) + } + + @Test("classifies all as creates when nothing exists") + internal func classifiesAllAsCreatesWhenNothingExists() { + let classification = OperationClassification( + proposedRecordNames: ["a", "b", "c"], + existingRecordNames: [] + ) + + #expect(classification.creates == ["a", "b", "c"]) + #expect(classification.updates.isEmpty) + } + + @Test("classifies all as updates when all already exist") + internal func classifiesAllAsUpdatesWhenAllAlreadyExist() { + let classification = OperationClassification( + proposedRecordNames: ["a", "b"], + existingRecordNames: ["a", "b", "c"] + ) + + #expect(classification.creates.isEmpty) + #expect(classification.updates == ["a", "b"]) + } + + @Test("collapses duplicate proposed names into a single set entry") + internal func collapsesDuplicateProposedNames() { + let classification = OperationClassification( + proposedRecordNames: ["a", "a", "b", "b", "b"], + existingRecordNames: ["b"] + ) + + #expect(classification.creates == ["a"]) + #expect(classification.updates == ["b"]) + } + + @Test("returns empty sets for empty inputs") + internal func returnsEmptySetsForEmptyInputs() { + let classification = OperationClassification( + proposedRecordNames: [], + existingRecordNames: [] + ) + + #expect(classification.creates.isEmpty) + #expect(classification.updates.isEmpty) + } + + @Test("classifies operations directly via convenience initializer") + internal func classifiesOperationsDirectly() { + let operations: [RecordOperation] = [ + .create(recordType: "Article", recordName: "new-1", fields: [:]), + .update( + recordType: "Article", + recordName: "existing-1", + fields: [:], + recordChangeTag: nil + ), + .create(recordType: "Article", recordName: "new-2", fields: [:]), + ] + + let classification = OperationClassification( + operations: operations, + existingRecordNames: ["existing-1"] + ) + + #expect(classification.creates == ["new-1", "new-2"]) + #expect(classification.updates == ["existing-1"]) + } + + @Test("skips anonymous operations that have no record name") + internal func skipsAnonymousOperations() { + let operations: [RecordOperation] = [ + .create(recordType: "Article", recordName: nil, fields: [:]), + .create(recordType: "Article", recordName: "named", fields: [:]), + ] + + let classification = OperationClassification( + operations: operations, + existingRecordNames: [] + ) + + #expect(classification.creates == ["named"]) + #expect(classification.updates.isEmpty) + } + + @Test("equates classifications with the same contents") + internal func equatesClassificationsWithSameContents() { + let lhs = OperationClassification(creates: ["a"], updates: ["b"]) + let rhs = OperationClassification(creates: ["a"], updates: ["b"]) + + #expect(lhs == rhs) + } +} diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift new file mode 100644 index 00000000..7999e050 --- /dev/null +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift @@ -0,0 +1,77 @@ +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +extension FilterBuilderTests { + @Suite("Comparators") + internal struct Comparators { + @Test("FilterBuilder creates EQUALS filter") + internal func equalsFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.equals("name", .string("John")) + #expect(filter.comparator == .EQUALS) + #expect(filter.fieldName == "name") + } + + @Test("FilterBuilder creates NOT_EQUALS filter") + internal func notEqualsFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.notEquals("age", .int64(25)) + #expect(filter.comparator == .NOT_EQUALS) + #expect(filter.fieldName == "age") + } + + @Test("FilterBuilder creates LESS_THAN filter") + internal func lessThanFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.lessThan("score", .double(100.0)) + #expect(filter.comparator == .LESS_THAN) + #expect(filter.fieldName == "score") + } + + @Test("FilterBuilder creates LESS_THAN_OR_EQUALS filter") + internal func lessThanOrEqualsFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.lessThanOrEquals("count", .int64(50)) + #expect(filter.comparator == .LESS_THAN_OR_EQUALS) + #expect(filter.fieldName == "count") + } + + @Test("FilterBuilder creates GREATER_THAN filter") + internal func greaterThanFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let date = Date() + let filter = FilterBuilder.greaterThan("createdAt", .date(date)) + #expect(filter.comparator == .GREATER_THAN) + #expect(filter.fieldName == "createdAt") + } + + @Test("FilterBuilder creates GREATER_THAN_OR_EQUALS filter") + internal func greaterThanOrEqualsFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.greaterThanOrEquals("priority", .int64(3)) + #expect(filter.comparator == .GREATER_THAN_OR_EQUALS) + #expect(filter.fieldName == "priority") + } + } +} diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift new file mode 100644 index 00000000..c011d1fd --- /dev/null +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift @@ -0,0 +1,48 @@ +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +extension FilterBuilderTests { + @Suite("Complex Values") + internal struct ComplexValues { + @Test("FilterBuilder handles boolean values") + internal func booleanValueFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.equals("isActive", FieldValue(booleanValue: true)) + #expect(filter.comparator == .EQUALS) + #expect(filter.fieldName == "isActive") + } + + @Test("FilterBuilder handles reference values") + internal func referenceValueFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let reference = Reference(recordName: "user-123") + let filter = FilterBuilder.equals("owner", .reference(reference)) + #expect(filter.comparator == .EQUALS) + #expect(filter.fieldName == "owner") + } + + @Test("FilterBuilder handles location values") + internal func locationValueFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let location = Location( + latitude: 37.7749, + longitude: -122.4194 + ) + let filter = FilterBuilder.equals("location", .location(location)) + #expect(filter.comparator == .EQUALS) + #expect(filter.fieldName == "location") + } + } +} diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift new file mode 100644 index 00000000..0111eff9 --- /dev/null +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift @@ -0,0 +1,93 @@ +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +extension FilterBuilderTests { + @Suite("List Filters") + internal struct ListFilters { + @Test("FilterBuilder creates IN filter") + internal func inFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let values: [FieldValue] = [.string("active"), .string("pending")] + let filter = FilterBuilder.in("status", values) + #expect(filter.comparator == .IN) + #expect(filter.fieldName == "status") + #expect(filter.fieldValue?._type == .STRING_LIST) + } + + @Test("FilterBuilder creates NOT_IN filter") + internal func notInFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let values: [FieldValue] = [.string("deleted"), .string("archived")] + let filter = FilterBuilder.notIn("status", values) + #expect(filter.comparator == .NOT_IN) + #expect(filter.fieldName == "status") + #expect(filter.fieldValue?._type == .STRING_LIST) + } + + @Test("FilterBuilder creates IN filter with numbers") + internal func inFilterWithNumbers() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let values: [FieldValue] = [.int64(1), .int64(2), .int64(3)] + let filter = FilterBuilder.in("categoryId", values) + #expect(filter.comparator == .IN) + #expect(filter.fieldName == "categoryId") + #expect(filter.fieldValue?._type == .INT64_LIST) + } + + @Test("FilterBuilder creates LIST_CONTAINS filter") + internal func listContainsFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.listContains("tags", .string("important")) + #expect(filter.comparator == .LIST_CONTAINS) + #expect(filter.fieldName == "tags") + } + + @Test("FilterBuilder creates NOT_LIST_CONTAINS filter") + internal func notListContainsFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.notListContains("tags", .string("spam")) + #expect(filter.comparator == .NOT_LIST_CONTAINS) + #expect(filter.fieldName == "tags") + } + + @Test("FilterBuilder creates LIST_MEMBER_BEGINS_WITH filter") + internal func listMemberBeginsWithFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.listMemberBeginsWith("emails", "admin@") + #expect(filter.comparator == .LIST_MEMBER_BEGINS_WITH) + #expect(filter.fieldName == "emails") + } + + @Test("FilterBuilder creates NOT_LIST_MEMBER_BEGINS_WITH filter") + internal func notListMemberBeginsWithFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.notListMemberBeginsWith("domains", "spam") + #expect(filter.comparator == .NOT_LIST_MEMBER_BEGINS_WITH) + #expect(filter.fieldName == "domains") + } + } +} diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+StringFilters.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+StringFilters.swift new file mode 100644 index 00000000..826651a7 --- /dev/null +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+StringFilters.swift @@ -0,0 +1,43 @@ +import Foundation +internal import MistKitOpenAPI +import Testing + +@testable import MistKit + +extension FilterBuilderTests { + @Suite("String Filters") + internal struct StringFilters { + @Test("FilterBuilder creates BEGINS_WITH filter") + internal func beginsWithFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.beginsWith("title", "Hello") + #expect(filter.comparator == .BEGINS_WITH) + #expect(filter.fieldName == "title") + } + + @Test("FilterBuilder creates NOT_BEGINS_WITH filter") + internal func notBeginsWithFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.notBeginsWith("email", "spam") + #expect(filter.comparator == .NOT_BEGINS_WITH) + #expect(filter.fieldName == "email") + } + + @Test("FilterBuilder creates CONTAINS_ALL_TOKENS filter") + internal func containsAllTokensFilter() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FilterBuilder is not available on this operating system.") + return + } + let filter = FilterBuilder.containsAllTokens("description", "swift cloudkit") + #expect(filter.comparator == .CONTAINS_ALL_TOKENS) + #expect(filter.fieldName == "description") + } + } +} diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests.swift new file mode 100644 index 00000000..0a6d334b --- /dev/null +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests.swift @@ -0,0 +1,4 @@ +import Testing + +@Suite("Filter Builder", .enabled(if: Platform.isCryptoAvailable)) +internal enum FilterBuilderTests {} diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+Comparison.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift similarity index 98% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+Comparison.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift index dbae43c1..2e4ce3d7 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+Comparison.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+ComplexFields.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift similarity index 95% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+ComplexFields.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift index bb4749ea..06dea963 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+ComplexFields.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit @@ -25,7 +26,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let reference = FieldValue.Reference(recordName: "parent-record-123") + let reference = Reference(recordName: "parent-record-123") let filter = QueryFilter.equals("parentRef", .reference(reference)) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .EQUALS) diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+EdgeCases.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift similarity index 98% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+EdgeCases.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift index a17e8f48..adb9a50b 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+EdgeCases.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+Equality.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift similarity index 97% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+Equality.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift index 815764b4..4024cca0 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+Equality.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+List.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift similarity index 98% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+List.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift index c2def251..71732e06 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+List.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+ListMember.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift similarity index 98% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+ListMember.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift index 0ced9a26..815327b4 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+ListMember.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests+String.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+String.swift similarity index 98% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests+String.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests+String.swift index 1ef9225a..0877d92d 100644 --- a/Tests/MistKitTests/PublicTypes/QueryFilterTests+String.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+String.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/PublicTypes/QueryFilterTests.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests.swift similarity index 100% rename from Tests/MistKitTests/PublicTypes/QueryFilterTests.swift rename to Tests/MistKitTests/Models/Queries/QueryFilterTests.swift diff --git a/Tests/MistKitTests/PublicTypes/QuerySortTests.swift b/Tests/MistKitTests/Models/Queries/QuerySortTests.swift similarity index 99% rename from Tests/MistKitTests/PublicTypes/QuerySortTests.swift rename to Tests/MistKitTests/Models/Queries/QuerySortTests.swift index 4b1dd740..ab534e62 100644 --- a/Tests/MistKitTests/PublicTypes/QuerySortTests.swift +++ b/Tests/MistKitTests/Models/Queries/QuerySortTests.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/Core/RecordInfo/RecordInfoTests.swift b/Tests/MistKitTests/Models/RecordInfoTests.swift similarity index 94% rename from Tests/MistKitTests/Core/RecordInfo/RecordInfoTests.swift rename to Tests/MistKitTests/Models/RecordInfoTests.swift index a5811f5b..26f8b898 100644 --- a/Tests/MistKitTests/Core/RecordInfo/RecordInfoTests.swift +++ b/Tests/MistKitTests/Models/RecordInfoTests.swift @@ -1,4 +1,5 @@ import Foundation +internal import MistKitOpenAPI import Testing @testable import MistKit diff --git a/Tests/MistKitTests/NetworkError/Storage/StorageTests.swift b/Tests/MistKitTests/NetworkError/Storage/StorageTests.swift deleted file mode 100644 index 45b52ce9..00000000 --- a/Tests/MistKitTests/NetworkError/Storage/StorageTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -extension NetworkErrorTests { - /// Network error storage tests - @Suite("Storage Tests", .enabled(if: Platform.isCryptoAvailable)) - internal struct StorageTests { - // MARK: - Test Data Setup - - private static let validAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - // MARK: - Token Storage Tests - - /// Tests token storage with network errors - @Test("Token storage with network errors") - internal func tokenStorageWithNetworkErrors() async throws { - let storage = InMemoryTokenStorage() - - // Store token - let credentials = TokenCredentials.apiToken(Self.validAPIToken) - try await storage.store(credentials, identifier: "test-key") - - // Retrieve token - let retrievedCredentials = try await storage.retrieve(identifier: "test-key") - #expect(retrievedCredentials != nil) - - if let retrieved = retrievedCredentials { - if case .apiToken(let token) = retrieved.method { - #expect(token == Self.validAPIToken) - } else { - Issue.record("Expected .apiToken method") - } - } - } - - /// Tests token storage persistence across network failures - @Test("Token storage persistence across network failures") - internal func tokenStoragePersistenceAcrossNetworkFailures() async throws { - let storage = InMemoryTokenStorage() - - // Store token - let credentials = TokenCredentials.apiToken(Self.validAPIToken) - try await storage.store(credentials, identifier: "persistent-key") - - // Simulate network failure during retrieval - let retrievedCredentials = try await storage.retrieve(identifier: "persistent-key") - #expect(retrievedCredentials != nil) - - // Verify token is still available after simulated network issues - if let retrieved = retrievedCredentials { - if case .apiToken(let token) = retrieved.method { - #expect(token == Self.validAPIToken) - } else { - Issue.record("Expected .apiToken method") - } - } - } - - /// Tests token storage cleanup after network errors - @Test("Token storage cleanup after network errors") - internal func tokenStorageCleanupAfterNetworkErrors() async throws { - let storage = InMemoryTokenStorage() - - // Store token - let credentials = TokenCredentials.apiToken(Self.validAPIToken) - try await storage.store(credentials, identifier: "cleanup-key") - - // Verify token exists - let initialRetrieval = try await storage.retrieve(identifier: "cleanup-key") - #expect(initialRetrieval != nil) - - // Remove token - try await storage.remove(identifier: "cleanup-key") - - // Verify token is removed - let finalRetrieval = try await storage.retrieve(identifier: "cleanup-key") - #expect(finalRetrieval == nil) - } - - /// Tests concurrent token storage operations - @Test("Concurrent token storage operations") - internal func concurrentTokenStorageOperations() async throws { - let storage = InMemoryTokenStorage() - - // Test concurrent storage operations - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await storage.storeToken(key: "concurrent-1", token: "token-1") } - group.addTask { try await storage.storeToken(key: "concurrent-2", token: "token-2") } - group.addTask { try await storage.storeToken(key: "concurrent-3", token: "token-3") } - - for try await _ in group {} - } - - // Verify all tokens were stored - let token1 = try await storage.retrieve(identifier: "concurrent-1") - let token2 = try await storage.retrieve(identifier: "concurrent-2") - let token3 = try await storage.retrieve(identifier: "concurrent-3") - - #expect(token1 != nil) - #expect(token2 != nil) - #expect(token3 != nil) - } - - /// Tests token storage with expiration - @Test("Token storage with expiration") - internal func tokenStorageWithExpiration() async throws { - let storage = InMemoryTokenStorage() - - // Store token with short expiration - let credentials = TokenCredentials.apiToken(Self.validAPIToken) - try await storage.store(credentials, identifier: "expiring-key") - - // Verify token exists initially - let initialRetrieval = try await storage.retrieve(identifier: "expiring-key") - #expect(initialRetrieval != nil) - - // Note: InMemoryTokenStorage doesn't have built-in expiration, - // but we can test the storage mechanism works - let finalRetrieval = try await storage.retrieve(identifier: "expiring-key") - #expect(finalRetrieval != nil) - } - } -} diff --git a/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+Advanced.swift b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+Advanced.swift new file mode 100644 index 00000000..ad0eddbf --- /dev/null +++ b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+Advanced.swift @@ -0,0 +1,188 @@ +// +// LoggingMiddlewareTests+Advanced.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension LoggingMiddlewareTests { + @Suite("Advanced") + internal struct Advanced { + @Test("LoggingMiddleware handles query parameters") + internal func handlesQueryParameters() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test?key=value&foo=bar" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let next: + (HTTPRequest, HTTPBody?, URL) async throws + -> (HTTPResponse, HTTPBody?) = { _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(response.status == .ok) + } + + @Test( + "LoggingMiddleware handles all HTTP methods", + arguments: [ + HTTPRequest.Method.get, + HTTPRequest.Method.post, + HTTPRequest.Method.put, + HTTPRequest.Method.delete, + HTTPRequest.Method.patch, + HTTPRequest.Method.head, + HTTPRequest.Method.options, + ] + ) + internal func handlesAllHTTPMethods( + method: HTTPRequest.Method + ) async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: method, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let next: + (HTTPRequest, HTTPBody?, URL) async throws + -> (HTTPResponse, HTTPBody?) = { _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(response.status == .ok) + } + + @Test("LoggingMiddleware handles large response bodies") + internal func handlesLargeResponseBodies() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let largeData = Data(repeating: 0x41, count: 100_000) + let responseBody = HTTPBody(largeData) + + let next: + (HTTPRequest, HTTPBody?, URL) async throws + -> (HTTPResponse, HTTPBody?) = { _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, responseBody) + } + + let (response, returnedBody) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(response.status == .ok) + #expect(returnedBody != nil) + } + + @Test("LoggingMiddleware handles concurrent requests") + internal func handlesConcurrentRequests() async throws { + let middleware = LoggingMiddleware() + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + try await withThrowingTaskGroup(of: HTTPResponse.Status.self) { group in + for requestIndex in 1...5 { + group.addTask { + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test/\(requestIndex)" + ) + let body: HTTPBody? = nil + + let next: + (HTTPRequest, HTTPBody?, URL) async throws + -> (HTTPResponse, HTTPBody?) = { _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test\(requestIndex)", + next: next + ) + + return response.status + } + } + + for try await status in group { + #expect(status == .ok) + } + } + } + } +} diff --git a/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+Basic.swift b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+Basic.swift new file mode 100644 index 00000000..ef12959f --- /dev/null +++ b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+Basic.swift @@ -0,0 +1,171 @@ +// +// LoggingMiddlewareTests+Basic.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension LoggingMiddlewareTests { + @Suite("Basic") + internal struct Basic { + @Test("LoggingMiddleware intercepts and passes through requests") + internal func interceptsAndPassesThrough() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/test/development/public/records/query" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + var nextCalled = false + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + nextCalled = true + let response = HTTPResponse(status: .ok) + return (response, nil) + } + + let (response, responseBody) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(nextCalled == true) + #expect(response.status == .ok) + #expect(responseBody == nil) + } + + @Test("LoggingMiddleware handles POST requests") + internal func handlesPostRequests() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .post, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/test/development/public/records/modify" + ) + let bodyData = Data("{\"records\":[]}".utf8) + let body = HTTPBody(bodyData) + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "modify", + next: next + ) + + #expect(response.status == .ok) + } + + @Test("LoggingMiddleware handles response bodies") + internal func handlesResponseBodies() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/test/development/public/records/query" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let responseBodyData = Data("{\"records\":[]}".utf8) + let responseBody = HTTPBody(responseBodyData) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, responseBody) + } + + let (response, returnedBody) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "query", + next: next + ) + + #expect(response.status == .ok) + #expect(returnedBody != nil) + } + + @Test("LoggingMiddleware propagates errors from next") + internal func propagatesErrors() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + enum TestError: Error { + case simulatedFailure + } + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + throw TestError.simulatedFailure + } + + do { + _ = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + Issue.record("Expected error to be propagated") + } catch { + #expect(error is TestError) + } + } + } +} diff --git a/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+StatusTests.swift b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+StatusTests.swift new file mode 100644 index 00000000..58781d10 --- /dev/null +++ b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests+StatusTests.swift @@ -0,0 +1,143 @@ +// +// LoggingMiddlewareTests+StatusTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MistKit + +extension LoggingMiddlewareTests { + @Suite("Status & Headers") + internal struct StatusTests { + @Test( + "LoggingMiddleware handles various HTTP status codes", + arguments: [ + HTTPResponse.Status.ok, + HTTPResponse.Status.created, + HTTPResponse.Status.accepted, + HTTPResponse.Status.noContent, + HTTPResponse.Status.badRequest, + HTTPResponse.Status.unauthorized, + HTTPResponse.Status.forbidden, + HTTPResponse.Status.notFound, + HTTPResponse.Status.internalServerError, + ] + ) + internal func handlesVariousStatusCodes(status: HTTPResponse.Status) async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + let response = HTTPResponse(status: status) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(response.status == status) + } + + @Test("LoggingMiddleware handles 421 Misdirected Request") + internal func handles421Status() async throws { + let middleware = LoggingMiddleware() + let request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test" + ) + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + let response = HTTPResponse(status: .init(code: 421, reasonPhrase: "Misdirected Request")) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(response.status.code == 421) + } + + @Test("LoggingMiddleware handles requests with headers") + internal func handlesRequestHeaders() async throws { + let middleware = LoggingMiddleware() + var request = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/test" + ) + request.headerFields[.authorization] = "Bearer token" + request.headerFields[.contentType] = "application/json" + + let body: HTTPBody? = nil + let baseURL = try #require(URL(string: "https://api.apple-cloudkit.com")) + + let next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + _, _, _ in + let response = HTTPResponse(status: .ok) + return (response, nil) + } + + let (response, _) = try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: next + ) + + #expect(response.status == .ok) + } + } +} diff --git a/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests.swift b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests.swift new file mode 100644 index 00000000..bc5ba8f9 --- /dev/null +++ b/Tests/MistKitTests/OpenAPI/LoggingMiddleware/LoggingMiddlewareTests.swift @@ -0,0 +1,33 @@ +// +// LoggingMiddlewareTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@Suite("Logging Middleware") +internal enum LoggingMiddlewareTests {} diff --git a/Tests/MistKitTests/Protocols/CloudKitRecordTests+Conformance.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Conformance.swift similarity index 100% rename from Tests/MistKitTests/Protocols/CloudKitRecordTests+Conformance.swift rename to Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Conformance.swift diff --git a/Tests/MistKitTests/Protocols/CloudKitRecordTests+FieldConversion.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+FieldConversion.swift similarity index 100% rename from Tests/MistKitTests/Protocols/CloudKitRecordTests+FieldConversion.swift rename to Tests/MistKitTests/RecordManagement/CloudKitRecordTests+FieldConversion.swift diff --git a/Tests/MistKitTests/Protocols/CloudKitRecordTests+Formatting.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Formatting.swift similarity index 100% rename from Tests/MistKitTests/Protocols/CloudKitRecordTests+Formatting.swift rename to Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Formatting.swift diff --git a/Tests/MistKitTests/Protocols/CloudKitRecordTests+Parsing.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Parsing.swift similarity index 100% rename from Tests/MistKitTests/Protocols/CloudKitRecordTests+Parsing.swift rename to Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Parsing.swift diff --git a/Tests/MistKitTests/Protocols/CloudKitRecordTests+RoundTrip.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+RoundTrip.swift similarity index 100% rename from Tests/MistKitTests/Protocols/CloudKitRecordTests+RoundTrip.swift rename to Tests/MistKitTests/RecordManagement/CloudKitRecordTests+RoundTrip.swift diff --git a/Tests/MistKitTests/Protocols/CloudKitRecordTests.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests.swift similarity index 100% rename from Tests/MistKitTests/Protocols/CloudKitRecordTests.swift rename to Tests/MistKitTests/RecordManagement/CloudKitRecordTests.swift diff --git a/Tests/MistKitTests/Protocols/FieldValueConvenienceTests.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift similarity index 98% rename from Tests/MistKitTests/Protocols/FieldValueConvenienceTests.swift rename to Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift index b1c987bb..66b9b10b 100644 --- a/Tests/MistKitTests/Protocols/FieldValueConvenienceTests.swift +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift @@ -130,7 +130,7 @@ internal struct FieldValueConvenienceTests { @Test("locationValue extracts Location from .location case") internal func locationValueExtraction() { - let location = FieldValue.Location( + let location = Location( latitude: 37.7749, longitude: -122.4194, horizontalAccuracy: 10.0 @@ -146,7 +146,7 @@ internal struct FieldValueConvenienceTests { @Test("referenceValue extracts Reference from .reference case") internal func referenceValueExtraction() { - let reference = FieldValue.Reference(recordName: "test-record") + let reference = Reference(recordName: "test-record") let value = FieldValue.reference(reference) #expect(value.referenceValue == reference) } @@ -158,7 +158,7 @@ internal struct FieldValueConvenienceTests { @Test("assetValue extracts Asset from .asset case") internal func assetValueExtraction() { - let asset = FieldValue.Asset( + let asset = Asset( fileChecksum: "abc123", size: 1_024, downloadURL: "https://example.com/file" diff --git a/Tests/MistKitTests/Protocols/MockRecordManagingService.swift b/Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift similarity index 93% rename from Tests/MistKitTests/Protocols/MockRecordManagingService.swift rename to Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift index 4704601a..32888dc0 100644 --- a/Tests/MistKitTests/Protocols/MockRecordManagingService.swift +++ b/Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift @@ -44,9 +44,12 @@ internal actor MockRecordManagingService: RecordManaging { return recordsToReturn } - internal func executeBatchOperations(_ operations: [RecordOperation], recordType: String) - async throws - { + internal func queryAllRecords(recordType: String) async throws -> [RecordInfo] { + queryCallCount += 1 + return recordsToReturn + } + + internal func executeBatchOperations(_ operations: [RecordOperation]) async throws { executeCallCount += 1 batchSizes.append(operations.count) lastExecutedOperations.append(contentsOf: operations) diff --git a/Tests/MistKitTests/Protocols/RecordManagingTests+List.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift similarity index 91% rename from Tests/MistKitTests/Protocols/RecordManagingTests+List.swift rename to Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift index 39bafa4e..20e91ca8 100644 --- a/Tests/MistKitTests/Protocols/RecordManagingTests+List.swift +++ b/Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift @@ -37,6 +37,10 @@ extension RecordManagingTests { internal struct List { @Test("list() calls queryRecords and doesn't throw") internal func listCallsQueryRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordManaging.list is not available on this operating system.") + return + } let service = MockRecordManagingService() await service.reset() diff --git a/Tests/MistKitTests/Protocols/RecordManagingTests+Query.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift similarity index 88% rename from Tests/MistKitTests/Protocols/RecordManagingTests+Query.swift rename to Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift index c35b4d15..06489b21 100644 --- a/Tests/MistKitTests/Protocols/RecordManagingTests+Query.swift +++ b/Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift @@ -37,6 +37,10 @@ extension RecordManagingTests { internal struct Query { @Test("query() returns parsed records") internal func queryReturnsParsedRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordManaging.query is not available on this operating system.") + return + } let service = MockRecordManagingService() // Set up mock data @@ -79,6 +83,10 @@ extension RecordManagingTests { @Test("query() with filter applies filtering") internal func queryWithFilter() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordManaging.query is not available on this operating system.") + return + } let service = MockRecordManagingService() await service.reset() @@ -123,6 +131,10 @@ extension RecordManagingTests { @Test("query() filters out nil parse results") internal func queryFiltersOutInvalidRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordManaging.query is not available on this operating system.") + return + } let service = MockRecordManagingService() await service.reset() @@ -164,6 +176,10 @@ extension RecordManagingTests { @Test("query() with no results returns empty array") internal func queryWithNoResults() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("RecordManaging.query is not available on this operating system.") + return + } let service = MockRecordManagingService() await service.reset() diff --git a/Tests/MistKitTests/Protocols/RecordManagingTests+Sync.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+Sync.swift similarity index 100% rename from Tests/MistKitTests/Protocols/RecordManagingTests+Sync.swift rename to Tests/MistKitTests/RecordManagement/RecordManagingTests+Sync.swift diff --git a/Tests/MistKitTests/Protocols/RecordManagingTests.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests.swift similarity index 100% rename from Tests/MistKitTests/Protocols/RecordManagingTests.swift rename to Tests/MistKitTests/RecordManagement/RecordManagingTests.swift diff --git a/Tests/MistKitTests/Protocols/TestRecord.swift b/Tests/MistKitTests/RecordManagement/TestRecord.swift similarity index 100% rename from Tests/MistKitTests/Protocols/TestRecord.swift rename to Tests/MistKitTests/RecordManagement/TestRecord.swift diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentitiesTests+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentitiesTests+Helpers.swift deleted file mode 100644 index d09daeef..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentitiesTests+Helpers.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// CloudKitServiceDiscoverUserIdentitiesTests+Helpers.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import Testing - -@testable import MistKit - -extension CloudKitServiceDiscoverUserIdentitiesTests { - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeSuccessfulService( - identityCount: Int = 1 - ) async throws -> CloudKitService { - let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( - identityCount: identityCount - ) - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() async throws -> CloudKitService { - let responseProvider = ResponseProvider.authenticationError() - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } -} - -// MARK: - DiscoverUserIdentities Response Builders - -extension ResponseProvider { - internal static func successfulDiscoverUserIdentities( - identityCount: Int = 1 - ) throws -> ResponseProvider { - ResponseProvider( - defaultResponse: try .successfulDiscoverUserIdentitiesResponse(identityCount: identityCount) - ) - } -} - -extension ResponseConfig { - internal static func successfulDiscoverUserIdentitiesResponse( - identityCount: Int = 1 - ) throws -> ResponseConfig { - var users: [[String: Any]] = [] - for index in 0.. CloudKitService { - let responseProvider = try ResponseProvider.successfulFetchChanges( - recordCount: recordCount, - moreComing: moreComing, - syncToken: syncToken - ) - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makePaginatedService( - pages: [(recordCount: Int, syncToken: String)] - ) async throws -> CloudKitService { - let provider = ResponseProvider( - defaultResponse: try .successfulFetchChangesResponse(moreComing: false) - ) - for (index, page) in pages.enumerated() { - let moreComing = index < pages.count - 1 - await provider.enqueue( - try .successfulFetchChangesResponse( - recordCount: page.recordCount, - moreComing: moreComing, - syncToken: page.syncToken - ), - for: "fetchRecordChanges" - ) - } - let transport = MockTransport(responseProvider: provider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() async throws -> CloudKitService { - let responseProvider = ResponseProvider.authenticationError() - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } -} - -// MARK: - FetchChanges Response Builders - -extension ResponseProvider { - internal static func successfulFetchChanges( - recordCount: Int = 2, - moreComing: Bool = false, - syncToken: String = "test-sync-token-abc" - ) throws -> ResponseProvider { - ResponseProvider( - defaultResponse: try .successfulFetchChangesResponse( - recordCount: recordCount, - moreComing: moreComing, - syncToken: syncToken - ) - ) - } -} - -extension ResponseConfig { - internal static func successfulFetchChangesResponse( - recordCount: Int = 2, - moreComing: Bool = false, - syncToken: String = "test-sync-token-abc" - ) throws -> ResponseConfig { - var records: [[String: Any]] = [] - for index in 0.. ResponseConfig - { - var records: [[String: Any]] = [] - for index in 0.. ResponseConfig { - let responseJSON = """ - { - "records": [ - { - "recordName": "deleted-record-0", - "recordType": "Note", - "recordChangeTag": "tag-0", - "deleted": true, - "fields": {} - }, - { - "recordName": "live-record-1", - "recordType": "Note", - "recordChangeTag": "tag-1", - "deleted": false, - "fields": {} - } - ], - "syncToken": "post-deletion-token", - "moreComing": false - } - """ - - var headers = HTTPFields() - headers[.contentType] = "application/json" - - return ResponseConfig( - statusCode: 200, - headers: headers, - body: responseJSON.data(using: .utf8), - error: nil - ) - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests+SuccessCases.swift deleted file mode 100644 index 62500750..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests+SuccessCases.swift +++ /dev/null @@ -1,213 +0,0 @@ -// -// CloudKitServiceFetchChangesTests+SuccessCases.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceFetchChangesTests { - @Suite("Success Cases") - internal struct SuccessCases { - @Test("fetchRecordChanges() returns records and sync token") - internal func fetchRecordChangesReturnsRecordsAndToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService( - recordCount: 3, - syncToken: "new-token-123" - ) - - let result = try await service.fetchRecordChanges() - - #expect(result.records.count == 3) - #expect(result.syncToken == "new-token-123") - #expect(result.moreComing == false) - } - - @Test("fetchRecordChanges() reports moreComing flag") - internal func fetchRecordChangesReportsMoreComing() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService( - recordCount: 2, - moreComing: true - ) - - let result = try await service.fetchRecordChanges() - - #expect(result.moreComing == true) - #expect(result.records.count == 2) - } - - @Test("fetchRecordChanges() works without sync token (initial fetch)") - internal func fetchRecordChangesWorksWithoutSyncToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService() - - let result = try await service.fetchRecordChanges(syncToken: nil) - - #expect(result.records.isEmpty == false) - #expect(result.syncToken != nil) - } - - @Test("fetchRecordChanges() returns record names and types") - internal func fetchRecordChangesReturnsRecordDetails() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService(recordCount: 2) - - let result = try await service.fetchRecordChanges() - - #expect(result.records[0].recordName == "record-0") - #expect(result.records[0].recordType == "Note") - #expect(result.records[1].recordName == "record-1") - } - - @Test("fetchAllRecordChanges() returns records when no pagination needed") - internal func fetchAllRecordChangesNoPagination() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService( - recordCount: 3, - moreComing: false, - syncToken: "final-token" - ) - - let (records, token) = try await service.fetchAllRecordChanges() - - #expect(records.count == 3) - #expect(token == "final-token") - } - - @Test("fetchAllRecordChanges() accumulates records across two pages") - internal func fetchAllRecordChangesMultiPage() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makePaginatedService(pages: [ - (recordCount: 3, syncToken: "token-1"), - (recordCount: 2, syncToken: "token-2"), - ]) - - let (records, token) = try await service.fetchAllRecordChanges() - - #expect(records.count == 5) - #expect(token == "token-2") - } - - @Test("fetchAllRecordChanges() uses final syncToken when moreComing transitions to false") - internal func fetchAllRecordChangesFinalSyncToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makePaginatedService(pages: [ - (recordCount: 1, syncToken: "interim-token"), - (recordCount: 1, syncToken: "final-token"), - ]) - - let (_, token) = try await service.fetchAllRecordChanges() - - #expect(token == "final-token") - } - - @Test("fetchAllRecordChanges() handles moreComing=true with empty first page") - internal func fetchAllRecordChangesEmptyFirstPage() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makePaginatedService(pages: [ - (recordCount: 0, syncToken: "token-1"), - (recordCount: 3, syncToken: "token-2"), - ]) - - let (records, token) = try await service.fetchAllRecordChanges() - - #expect(records.count == 3) - #expect(token == "token-2") - } - - @Test("fetchAllRecordChanges() accumulates records across three pages") - internal func fetchAllRecordChangesThreePage() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makePaginatedService(pages: [ - (recordCount: 2, syncToken: "token-1"), - (recordCount: 3, syncToken: "token-2"), - (recordCount: 2, syncToken: "token-3"), - ]) - - let (records, token) = try await service.fetchAllRecordChanges() - - #expect(records.count == 7) - #expect(token == "token-3") - } - - @Test("fetchRecordChanges() surfaces deleted records with deleted flag set") - internal func fetchRecordChangesSurfacesDeletedRecords() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let responseProvider = ResponseProvider( - defaultResponse: .fetchChangesResponseWithDeletedRecord() - ) - let transport = MockTransport(responseProvider: responseProvider) - let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234", - transport: transport - ) - - let result = try await service.fetchRecordChanges() - - #expect(result.records.count == 2) - let deletedRecord = try #require(result.records.first { $0.recordName == "deleted-record-0" }) - #expect(deletedRecord.deleted == true) - let liveRecord = try #require(result.records.first { $0.recordName == "live-record-1" }) - #expect(liveRecord.deleted == false) - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests+Validation.swift deleted file mode 100644 index a46385a3..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests+Validation.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// CloudKitServiceFetchChangesTests+Validation.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceFetchChangesTests { - @Suite("Validation") - internal struct Validation { - @Test("fetchRecordChanges() throws 400 for limit of 0") - internal func fetchRecordChangesThrowsForZeroLimit() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService() - - await #expect { - try await service.fetchRecordChanges(resultsLimit: 0) - } throws: { error in - guard let ckError = error as? CloudKitError, - case .httpErrorWithRawResponse(let status, _) = ckError - else { return false } - return status == 400 - } - } - - @Test("fetchRecordChanges() throws 400 for limit over 200") - internal func fetchRecordChangesThrowsForLimitOver200() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService() - - await #expect { - try await service.fetchRecordChanges(resultsLimit: 201) - } throws: { error in - guard let ckError = error as? CloudKitError, - case .httpErrorWithRawResponse(let status, _) = ckError - else { return false } - return status == 400 - } - } - - @Test("fetchRecordChanges() accepts valid limit values") - internal func fetchRecordChangesAcceptsValidLimits() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService() - - // Minimum valid limit - let result1 = try await service.fetchRecordChanges(resultsLimit: 1) - #expect(result1.records.isEmpty == false || result1.syncToken != nil) - - // Maximum valid limit - let result200 = try await service.fetchRecordChanges(resultsLimit: 200) - #expect(result200.records.isEmpty == false || result200.syncToken != nil) - } - - @Test("fetchAllRecordChanges() throws invalidResponse for moreComing:true with nil syncToken") - internal func fetchAllRecordChangesThrowsForNilSyncTokenWithMoreComing() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let responseProvider = ResponseProvider( - defaultResponse: .fetchChangesResponseMoreComingNilToken(recordCount: 2) - ) - let transport = MockTransport(responseProvider: responseProvider) - let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234", - transport: transport - ) - - await #expect(throws: CloudKitError.self) { - _ = try await service.fetchAllRecordChanges() - } - } - - @Test("fetchAllRecordChanges() breaks out when server returns stuck token with no records") - internal func fetchAllRecordChangesEscapesStuckToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchChangesTests.makeSuccessfulService( - recordCount: 0, - moreComing: true, - syncToken: "stuck-token" - ) - - let (records, token) = try await service.fetchAllRecordChanges() - - #expect(records.isEmpty) - #expect(token == "stuck-token") - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests.swift deleted file mode 100644 index 58386ef7..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChangesTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CloudKitServiceFetchChangesTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("CloudKitService FetchChanges Operations", .enabled(if: Platform.isCryptoAvailable)) -internal enum CloudKitServiceFetchChangesTests {} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+Helpers.swift deleted file mode 100644 index c283a3f5..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+Helpers.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// CloudKitServiceFetchZoneChangesTests+Helpers.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import Testing - -@testable import MistKit - -extension CloudKitServiceFetchZoneChangesTests { - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeSuccessfulService( - zoneCount: Int = 1, - syncToken: String = "zone-sync-token-abc" - ) async throws -> CloudKitService { - let responseProvider = try ResponseProvider.successfulFetchZoneChanges( - zoneCount: zoneCount, - syncToken: syncToken - ) - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() async throws -> CloudKitService { - let responseProvider = ResponseProvider.authenticationError() - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } -} - -// MARK: - FetchZoneChanges Response Builders - -extension ResponseProvider { - internal static func successfulFetchZoneChanges( - zoneCount: Int = 1, - syncToken: String = "zone-sync-token-abc" - ) throws -> ResponseProvider { - ResponseProvider( - defaultResponse: try .successfulFetchZoneChangesResponse( - zoneCount: zoneCount, - syncToken: syncToken - ) - ) - } -} - -extension ResponseConfig { - internal static func successfulFetchZoneChangesResponse( - zoneCount: Int = 1, - syncToken: String = "zone-sync-token-abc" - ) throws -> ResponseConfig { - var zones: [[String: Any]] = [] - for index in 0.. ResponseConfig { - let responseJSON = """ - { - "zones": [ - { - "zoneID": { - "zoneName": "valid-zone", - "ownerName": "_defaultOwner" - } - }, - {} - ], - "syncToken": "token-with-nil-zone" - } - """ - - var headers = HTTPFields() - headers[.contentType] = "application/json" - - return ResponseConfig( - statusCode: 200, - headers: headers, - body: responseJSON.data(using: .utf8), - error: nil - ) - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+SuccessCases.swift deleted file mode 100644 index e89df790..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+SuccessCases.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// CloudKitServiceFetchZoneChangesTests+SuccessCases.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceFetchZoneChangesTests { - @Suite("Success Cases") - internal struct SuccessCases { - @Test("fetchZoneChanges() returns zones and sync token") - internal func fetchZoneChangesReturnsZonesAndToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchZoneChangesTests.makeSuccessfulService( - zoneCount: 2, - syncToken: "zone-token-xyz" - ) - - let result = try await service.fetchZoneChanges() - - #expect(result.zones.count == 2) - #expect(result.syncToken == "zone-token-xyz") - } - - @Test("fetchZoneChanges() returns zone names") - internal func fetchZoneChangesReturnsZoneNames() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchZoneChangesTests.makeSuccessfulService( - zoneCount: 1 - ) - - let result = try await service.fetchZoneChanges() - - #expect(result.zones.first?.zoneName == "test-zone-0") - } - - @Test("fetchZoneChanges() returns empty zones array when no changes") - internal func fetchZoneChangesReturnsEmptyArray() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchZoneChangesTests.makeSuccessfulService( - zoneCount: 0 - ) - - let result = try await service.fetchZoneChanges() - - #expect(result.zones.isEmpty) - #expect(result.syncToken != nil) - } - - @Test("fetchZoneChanges() works with sync token (incremental fetch)") - internal func fetchZoneChangesWorksWithSyncToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchZoneChangesTests.makeSuccessfulService( - zoneCount: 1, - syncToken: "new-token" - ) - - let result = try await service.fetchZoneChanges(syncToken: "previous-token") - - #expect(result.zones.count == 1) - #expect(result.syncToken == "new-token") - } - - @Test("fetchZoneChanges() filters out zones with nil zoneID from server response") - internal func fetchZoneChangesFiltersNilZoneID() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let responseProvider = ResponseProvider( - defaultResponse: .zoneChangesResponseWithNilZoneID() - ) - let transport = MockTransport(responseProvider: responseProvider) - let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234", - transport: transport - ) - - let result = try await service.fetchZoneChanges() - - #expect(result.zones.count == 1, "Zone with nil zoneID should be filtered out") - #expect(result.zones.first?.zoneName == "valid-zone") - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+Validation.swift deleted file mode 100644 index f56a2e57..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests+Validation.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// CloudKitServiceFetchZoneChangesTests+Validation.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceFetchZoneChangesTests { - @Suite("Validation") - internal struct Validation { - @Test("fetchZoneChanges() throws on authentication error") - internal func fetchZoneChangesThrowsOnAuthError() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceFetchZoneChangesTests.makeAuthErrorService() - - await #expect(throws: CloudKitError.self) { - try await service.fetchZoneChanges() - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests.swift deleted file mode 100644 index 3a44eb78..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChangesTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CloudKitServiceFetchZoneChangesTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("CloudKitService FetchZoneChanges Operations", .enabled(if: Platform.isCryptoAvailable)) -internal enum CloudKitServiceFetchZoneChangesTests {} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupZonesTests+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupZonesTests+Helpers.swift deleted file mode 100644 index 0476a4ac..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupZonesTests+Helpers.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// CloudKitServiceLookupZonesTests+Helpers.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import Testing - -@testable import MistKit - -extension CloudKitServiceLookupZonesTests { - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeSuccessfulService( - zoneCount: Int = 1 - ) async throws -> CloudKitService { - let responseProvider = try ResponseProvider.successfulLookupZones(zoneCount: zoneCount) - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() async throws -> CloudKitService { - let responseProvider = ResponseProvider.authenticationError() - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } -} - -// MARK: - LookupZones Response Builders - -extension ResponseProvider { - internal static func successfulLookupZones(zoneCount: Int = 1) throws -> ResponseProvider { - ResponseProvider(defaultResponse: try .successfulLookupZonesResponse(zoneCount: zoneCount)) - } -} - -extension ResponseConfig { - internal static func successfulLookupZonesResponse(zoneCount: Int = 1) throws -> ResponseConfig { - var zones: [[String: Any]] = [] - for index in 0.. CloudKitService { - let transport = MockTransport( - responseProvider: .validationError(errorType) - ) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "test-token", - transport: transport - ) - } - - /// Create service for successful operations - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeSuccessfulService( - records: [String: Any] = [:] - ) throws -> CloudKitService { - let transport = MockTransport( - responseProvider: .successfulQuery(records: records) - ) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "test-token", - transport: transport - ) - } - - /// Create service for auth errors - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() throws -> CloudKitService { - let transport = MockTransport( - responseProvider: .authenticationError() - ) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "test-token", - transport: transport - ) - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceQueryTests+SortConversion.swift b/Tests/MistKitTests/Service/CloudKitServiceQueryTests+SortConversion.swift deleted file mode 100644 index 493ad64d..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceQueryTests+SortConversion.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// CloudKitServiceQueryTests+SortConversion.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2025 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceQueryTests { - @Suite("Sort Conversion") - internal struct SortConversion { - @Test("QuerySort converts to Components.Schemas format correctly") - internal func querySortConvertsToComponentsFormat() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - // Test ascending sort - let ascendingSort = QuerySort.ascending("createdAt") - let componentsAsc = Components.Schemas.Sort(from: ascendingSort) - - #expect(componentsAsc.fieldName == "createdAt") - #expect(componentsAsc.ascending == true) - - // Test descending sort - let descendingSort = QuerySort.descending("modifiedAt") - let componentsDesc = Components.Schemas.Sort(from: descendingSort) - - #expect(componentsDesc.fieldName == "modifiedAt") - #expect(componentsDesc.ascending == false) - } - - @Test("QuerySort handles various field name formats") - internal func querySortHandlesVariousFieldNameFormats() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let fieldNames = [ - "simpleField", - "camelCaseField", - "snake_case_field", - "field123", - "field_with_multiple_underscores", - ] - - for fieldName in fieldNames { - let sort = QuerySort.ascending(fieldName) - let components = Components.Schemas.Sort(from: sort) - - #expect(components.fieldName == fieldName, "Failed for field name: \(fieldName)") - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceQueryTests+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceQueryTests+Validation.swift deleted file mode 100644 index f5db3216..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceQueryTests+Validation.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// CloudKitServiceQueryTests+Validation.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2025 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceQueryTests { - @Suite("Validation") - internal struct Validation { - @Test("queryRecords() validates empty recordType") - internal func queryRecordsValidatesEmptyRecordType() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try CloudKitServiceQueryTests.makeValidationErrorService(.emptyRecordType) - - do { - _ = try await service.queryRecords(recordType: "") - Issue.record("Expected error for empty recordType") - } catch let error as CloudKitError { - // Verify we get the correct validation error - if case .httpErrorWithRawResponse(let statusCode, let response) = error { - #expect(statusCode == 400) - #expect(response.contains("recordType cannot be empty")) - } else { - Issue.record("Expected httpErrorWithRawResponse error") - } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") - } - } - - @Test("queryRecords() validates limit too small", arguments: [-1, 0]) - internal func queryRecordsValidatesLimitTooSmall(limit: Int) async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try CloudKitServiceQueryTests.makeValidationErrorService(.limitTooSmall(limit)) - - do { - _ = try await service.queryRecords(recordType: "Article", limit: limit) - Issue.record("Expected error for limit \(limit)") - } catch { - if case .httpErrorWithRawResponse(let statusCode, let response) = error { - #expect(statusCode == 400) - #expect(response.contains("limit must be between 1 and 200")) - } else { - Issue.record("Expected httpErrorWithRawResponse error") - } - } - } - - @Test("queryRecords() validates limit too large", arguments: [201, 300, 1_000]) - internal func queryRecordsValidatesLimitTooLarge(limit: Int) async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try CloudKitServiceQueryTests.makeValidationErrorService(.limitTooLarge(limit)) - - do { - _ = try await service.queryRecords(recordType: "Article", limit: limit) - Issue.record("Expected error for limit \(limit)") - } catch { - if case .httpErrorWithRawResponse(let statusCode, let response) = error { - #expect(statusCode == 400) - #expect(response.contains("limit must be between 1 and 200")) - } else { - Issue.record("Expected httpErrorWithRawResponse error") - } - } - } - - @Test("queryRecords() accepts valid limit range", arguments: [1, 50, 100, 200]) - internal func queryRecordsAcceptsValidLimitRange(limit: Int) async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try CloudKitServiceQueryTests.makeSuccessfulService() - - // This test verifies validation passes - actual API call will fail without real credentials - // but we're testing that validation doesn't throw - do { - _ = try await service.queryRecords(recordType: "Article", limit: limit) - Issue.record("Expected network error since we don't have real credentials") - } catch { - // We expect a network/auth error, not a validation error - // Validation errors have status code 400 - if case .httpErrorWithRawResponse(let statusCode, _) = error { - #expect(statusCode != 400, "Validation should not fail for limit \(limit)") - } - // Other CloudKit errors are expected (auth, network, etc.) - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceQueryTests.swift b/Tests/MistKitTests/Service/CloudKitServiceQueryTests.swift deleted file mode 100644 index f9355ce9..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceQueryTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CloudKitServiceQueryTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2025 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("CloudKitService Query Operations", .enabled(if: Platform.isCryptoAvailable)) -internal enum CloudKitServiceQueryTests {} diff --git a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+ErrorHandling.swift b/Tests/MistKitTests/Service/CloudKitServiceUploadTests+ErrorHandling.swift deleted file mode 100644 index 276ea7cf..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+ErrorHandling.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// CloudKitServiceUploadTests+ErrorHandling.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceUploadTests { - @Suite("Error Handling") - internal struct ErrorHandling { - @Test("uploadAssets() handles unauthorized error (401)") - internal func uploadAssetsHandlesUnauthorizedError() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeAuthErrorService() - let testData = Data(count: 1_024) - - do { - _ = try await service.uploadAssets( - data: testData, - recordType: "Note", - fieldName: "image" - ) - Issue.record("Expected authentication error") - } catch let error as CloudKitError { - if case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason) = error { - #expect(statusCode == 401, "Should return 401 Unauthorized") - #expect(serverErrorCode == "AUTHENTICATION_FAILED") - #expect(reason == "Authentication failed") - } else { - Issue.record("Expected httpErrorWithDetails error, got \(error)") - } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") - } - } - - @Test("uploadAssets() handles bad request error (400)") - internal func uploadAssetsHandlesBadRequestError() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeUploadValidationErrorService( - .emptyData - ) - let testData = Data() // Empty data triggers 400 - - do { - _ = try await service.uploadAssets( - data: testData, - recordType: "Note", - fieldName: "image" - ) - Issue.record("Expected bad request error") - } catch let error as CloudKitError { - if case .httpErrorWithRawResponse(let statusCode, _) = error { - #expect(statusCode == 400, "Should return 400 Bad Request") - } else { - Issue.record("Expected httpErrorWithRawResponse error, got \(error)") - } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceUploadTests+Helpers.swift deleted file mode 100644 index 2c39973b..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+Helpers.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// CloudKitServiceUploadTests+Helpers.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import HTTPTypes -import Testing - -@testable import MistKit - -/// Types of upload validation errors that can occur -internal enum UploadValidationErrorType: Sendable { - case emptyData - case oversizedAsset(Int) -} - -extension CloudKitServiceUploadTests { - /// Create service for successful upload operations - /// Test API token in 64-character hexadecimal format as required by MistKit validation - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - /// Create a mock asset uploader that returns a successful upload response - internal static func makeMockAssetUploader() -> AssetUploader { - { data, _ in - let response = """ - { - "singleFile": { - "wrappingKey": "test-wrapping-key-abc123", - "fileChecksum": "test-checksum-def456", - "receipt": "test-receipt-token-xyz", - "referenceChecksum": "test-ref-checksum-789", - "size": \(data.count) - } - } - """ - return (200, Data(response.utf8)) - } - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeSuccessfulUploadService( - tokenCount: Int = 1 - ) async throws -> CloudKitService { - let responseProvider = ResponseProvider.successfulUpload(tokenCount: tokenCount) - - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - /// Create service for validation error testing - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeUploadValidationErrorService( - _ errorType: UploadValidationErrorType - ) async throws -> CloudKitService { - let responseProvider = ResponseProvider.uploadValidationError(errorType) - - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } - - /// Create service for auth errors - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() async throws -> CloudKitService { - let responseProvider = ResponseProvider.authenticationError() - - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: testAPIToken, - transport: transport - ) - } -} - -// MARK: - Upload Response Builders - -extension ResponseProvider { - /// Response provider for successful upload operations - internal static func successfulUpload(tokenCount: Int = 1) -> ResponseProvider { - ResponseProvider(defaultResponse: .successfulUploadResponse(tokenCount: tokenCount)) - } - - /// Response provider for upload validation errors - internal static func uploadValidationError(_ type: UploadValidationErrorType) -> ResponseProvider - { - ResponseProvider(defaultResponse: .uploadValidationError(type)) - } -} - -extension ResponseConfig { - /// Creates a successful asset upload response - /// - /// - Parameter tokenCount: Number of upload tokens to include in response - /// - Returns: ResponseConfig with successful upload response - internal static func successfulUploadResponse(tokenCount: Int = 1) -> ResponseConfig { - var tokens: [[String: Any]] = [] - for index in 0.. ResponseConfig { - let responseJSON = """ - { - "tokens": [ - { - "url": "https://cvws.icloud-content.com/test-upload-url", - "fieldName": "file" - } - ] - } - """ - - var headers = HTTPFields() - headers[.contentType] = "application/json" - - return ResponseConfig( - statusCode: 200, - headers: headers, - body: responseJSON.data(using: .utf8), - error: nil - ) - } - - /// Creates an upload validation error response (400 Bad Request) - /// - /// - Parameter type: The type of upload validation error - /// - Returns: ResponseConfig with appropriate validation error message - internal static func uploadValidationError(_ type: UploadValidationErrorType) -> ResponseConfig { - let reason: String - switch type { - case .emptyData: - reason = "Asset data cannot be empty" - case .oversizedAsset(let size): - reason = "Asset size \(size) bytes exceeds maximum allowed size of 262144000 bytes (250 MB)" - } - - return cloudKitError( - statusCode: 400, - serverErrorCode: "BAD_REQUEST", - reason: reason - ) - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceUploadTests+SuccessCases.swift deleted file mode 100644 index 781eab9b..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+SuccessCases.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// CloudKitServiceUploadTests+SuccessCases.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceUploadTests { - @Suite("Success Cases") - internal struct SuccessCases { - @Test("uploadAssets() successfully uploads valid asset") - internal func uploadAssetsSuccessfullyUploadsValidAsset() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeSuccessfulUploadService(tokenCount: 1) - let testData = Data(count: 1_024) // 1 KB of test data - - let result = try await service.uploadAssets( - data: testData, - recordType: "Note", - fieldName: "image", - using: CloudKitServiceUploadTests.makeMockAssetUploader() - ) - - #expect(result.recordName.isEmpty == false, "Result should have a record name") - #expect(result.fieldName == "file", "Result should have the field name from mock response") - #expect(result.asset.receipt != nil, "Asset should have a receipt from CloudKit") - } - - @Test("uploadAssets() parses single token from response") - internal func uploadAssetsParseSingleToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeSuccessfulUploadService(tokenCount: 1) - let testData = Data(count: 2_048) - - let result = try await service.uploadAssets( - data: testData, - recordType: "Note", - fieldName: "image", - using: CloudKitServiceUploadTests.makeMockAssetUploader() - ) - - #expect(result.recordName == "test-record-0") - #expect(result.fieldName == "file") - #expect(result.asset.receipt != nil) - } - - @Test("uploadAssets() returns a single token") - internal func uploadAssetsReturnsSingleToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeSuccessfulUploadService(tokenCount: 1) - let testData = Data(count: 4_096) - - let result = try await service.uploadAssets( - data: testData, - recordType: "Note", - fieldName: "image", - using: CloudKitServiceUploadTests.makeMockAssetUploader() - ) - - #expect(result.recordName == "test-record-0") - #expect(result.fieldName == "file") - #expect(result.asset.receipt != nil) - } - - @Test("requestAssetUploadURL() returns token with url, recordName, and fieldName") - internal func requestAssetUploadURLReturnsToken() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeSuccessfulUploadService(tokenCount: 1) - - let token = try await service.requestAssetUploadURL( - recordType: "Note", - fieldName: "image" - ) - - #expect(token.url != nil) - #expect(token.recordName == "test-record-0") - #expect(token.fieldName == "file") - } - - @Test("uploadAssets() invokes injected AssetUploader closure, not URLSession.shared") - internal func uploadAssetsInvokesInjectedUploader() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeSuccessfulUploadService(tokenCount: 1) - - actor CallTracker { - private(set) var callCount = 0 - func record() { callCount += 1 } - } - let tracker = CallTracker() - - let trackingUploader: AssetUploader = { data, _ in - await tracker.record() - let response = """ - { - "singleFile": { - "wrappingKey": "key", - "fileChecksum": "cs", - "receipt": "rcpt", - "referenceChecksum": "rcs", - "size": \(data.count) - } - } - """ - return (200, Data(response.utf8)) - } - - _ = try await service.uploadAssets( - data: Data(count: 1_024), - recordType: "Note", - fieldName: "image", - using: trackingUploader - ) - - let count = await tracker.callCount - #expect(count == 1, "Custom uploader should have been called exactly once") - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceUploadTests+Validation.swift deleted file mode 100644 index baaa95b9..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceUploadTests+Validation.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// CloudKitServiceUploadTests+Validation.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceUploadTests { - @Suite("Validation") - internal struct Validation { - @Test("uploadAssets() validates empty data") - internal func uploadAssetsValidatesEmptyData() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeUploadValidationErrorService( - .emptyData - ) - - do { - _ = try await service.uploadAssets( - data: Data(), - recordType: "Note", - fieldName: "image" - ) - Issue.record("Expected error for empty data") - } catch let error as CloudKitError { - // Verify we get the correct validation error - if case .httpErrorWithRawResponse(let statusCode, let response) = error { - #expect(statusCode == 400) - #expect(response.contains("Asset data cannot be empty")) - } else { - Issue.record("Expected httpErrorWithRawResponse error") - } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") - } - } - - @Test("uploadAssets() validates 15 MB size limit", .disabled(if: Platform.isWasm)) - internal func uploadAssetsValidates15MBLimit() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - // Create data just over 15 MB (15 * 1024 * 1024 + 1 bytes) - let oversizedData = Data(count: 15_728_641) - let service = try await CloudKitServiceUploadTests.makeUploadValidationErrorService( - .oversizedAsset(oversizedData.count) - ) - - do { - _ = try await service.uploadAssets( - data: oversizedData, - recordType: "Note", - fieldName: "image" - ) - Issue.record("Expected error for oversized asset") - } catch let error as CloudKitError { - // Verify we get the correct validation error - if case .httpErrorWithRawResponse(let statusCode, let response) = error { - #expect(statusCode == 413) - #expect(response.contains("exceeds maximum")) - } else { - Issue.record("Expected httpErrorWithRawResponse error, got \(error)") - } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") - } - } - - @Test("uploadAssets() accepts valid data sizes", .disabled(if: Platform.isWasm)) - internal func uploadAssetsAcceptsValidSizes() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceUploadTests.makeSuccessfulUploadService() - - // Test various valid sizes (CloudKit limit is 15 MB) - let validSizes = [ - 1, // 1 byte - 1_024, // 1 KB - 1_024 * 1_024, // 1 MB - 10 * 1_024 * 1_024, // 10 MB - 15 * 1_024 * 1_024, // Exactly 15 MB (maximum allowed) - ] - - for size in validSizes { - let data = Data(count: size) - do { - let result = try await service.uploadAssets( - data: data, - recordType: "Note", - fieldName: "image", - using: CloudKitServiceUploadTests.makeMockAssetUploader() - ) - #expect(result.asset.receipt != nil, "Should receive asset with receipt") - } catch { - Issue.record("Valid size \(size) bytes should not throw error: \(error)") - } - } - } - - @Test("uploadAssets() throws invalidResponse when CloudKit returns token with no recordName") - internal func uploadAssetsThrowsWhenRecordNameIsNil() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let responseProvider = ResponseProvider( - defaultResponse: .uploadResponseWithNilRecordName() - ) - let transport = MockTransport(responseProvider: responseProvider) - let service = try CloudKitService( - containerIdentifier: "iCloud.com.example.test", - apiToken: "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234", - transport: transport - ) - - await #expect(throws: CloudKitError.self) { - _ = try await service.uploadAssets( - data: Data(count: 1_024), - recordType: "Note", - fieldName: "image", - using: CloudKitServiceUploadTests.makeMockAssetUploader() - ) - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceUploadTests.swift b/Tests/MistKitTests/Service/CloudKitServiceUploadTests.swift deleted file mode 100644 index f039b105..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceUploadTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CloudKitServiceUploadTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("CloudKitService Upload Operations", .enabled(if: Platform.isCryptoAvailable)) -internal enum CloudKitServiceUploadTests {} diff --git a/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshBasicTests.swift b/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshBasicTests.swift deleted file mode 100644 index 8da9360c..00000000 --- a/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshBasicTests.swift +++ /dev/null @@ -1,163 +0,0 @@ -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -@Suite("Concurrent Token Refresh Basic Tests") -/// Test suite for basic concurrent token refresh functionality -internal struct ConcurrentTokenRefreshBasicTests { - // MARK: - Helper Methods - - /// Creates a standard test request for concurrent token refresh tests - private func createTestRequest() -> HTTPRequest { - HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - } - - /// Creates a standard next handler that returns success - private func createSuccessNextHandler() - -> @Sendable (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) - { - { _, _, _ in (HTTPResponse(status: .ok), nil) } - } - - /// Executes concurrent middleware calls and returns results - private func executeConcurrentMiddlewareCalls( - middleware: AuthenticationMiddleware, - request: HTTPRequest, - baseURL: URL, - next: - @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> ( - HTTPResponse, HTTPBody? - ), - count: Int - ) async -> [Bool] { - let tasks = (1...count).map { _ in - Task { - await middleware.interceptWithMiddleware( - request: request, - baseURL: baseURL, - operationID: "test-operation", - next: next - ) - } - } - - return await withTaskGroup(of: Bool.self) { group in - for task in tasks { - group.addTask { await task.value } - } - - var results: [Bool] = [] - for await result in group { - results.append(result) - } - return results - } - } - - // MARK: - Basic Concurrent Token Refresh Tests - - /// Tests concurrent token refresh with multiple requests - @Test("Concurrent token refresh with multiple requests") - internal func concurrentTokenRefreshWithMultipleRequests() async throws { - let mockTokenManager = MockTokenManagerWithRefresh() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let request = createTestRequest() - let next = createSuccessNextHandler() - let baseURL = URL.MistKit.cloudKitAPI - - // Test concurrent access patterns - let results = await executeConcurrentMiddlewareCalls( - middleware: middleware, - request: request, - baseURL: baseURL, - next: next, - count: 5 - ) - - // Verify all requests succeeded - for result in results { - #expect(result == true) - } - - // Verify that refresh was called for each concurrent request - #expect(await mockTokenManager.refreshCallCount == 5) - } - - /// Tests concurrent token refresh with different token managers - @Test("Concurrent token refresh with different token managers") - internal func concurrentTokenRefreshWithDifferentTokenManagers() async throws { - let tokenManagers = [ - MockTokenManagerWithRefresh(), - MockTokenManagerWithRefresh(), - MockTokenManagerWithRefresh(), - ] - - let middlewares = tokenManagers.map { AuthenticationMiddleware(tokenManager: $0) } - - let request = createTestRequest() - let next = createSuccessNextHandler() - let baseURL = URL.MistKit.cloudKitAPI - - // Test concurrent access with different middlewares - let results = await executeConcurrentMiddlewareCallsWithDifferentMiddlewares( - middlewares: middlewares, - request: request, - baseURL: baseURL, - next: next - ) - - // Verify all requests succeeded - for result in results { - #expect(result == true) - } - - // Each token manager should have refreshed once - for tokenManager in tokenManagers { - #expect(await tokenManager.refreshCallCount == 1) - } - } - - /// Executes concurrent middleware calls with different middlewares - private func executeConcurrentMiddlewareCallsWithDifferentMiddlewares( - middlewares: [AuthenticationMiddleware], - request: HTTPRequest, - baseURL: URL, - next: - @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> ( - HTTPResponse, HTTPBody? - ) - ) async -> [Bool] { - let tasks = middlewares.map { middleware in - Task { - await middleware.interceptWithMiddleware( - request: request, - baseURL: baseURL, - operationID: "test-operation", - next: next - ) - } - } - - return await withTaskGroup(of: Bool.self) { group in - for task in tasks { - group.addTask { await task.value } - } - - var results: [Bool] = [] - for await result in group { - results.append(result) - } - return results - } - } -} diff --git a/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshErrorTests.swift b/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshErrorTests.swift deleted file mode 100644 index 5f39470f..00000000 --- a/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshErrorTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -@Suite("Concurrent Token Refresh Error Tests") -/// Test suite for concurrent token refresh error handling functionality -internal struct ConcurrentTokenRefreshErrorTests { - // MARK: - Helper Methods - - /// Creates a standard test request for concurrent token refresh tests - private func createTestRequest() -> HTTPRequest { - HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - } - - /// Creates a standard next handler that returns success - private func createSuccessNextHandler() - -> @Sendable (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) - { - { _, _, _ in (HTTPResponse(status: .ok), nil) } - } - - /// Executes concurrent middleware calls and returns results - private func executeConcurrentMiddlewareCalls( - middleware: AuthenticationMiddleware, - request: HTTPRequest, - baseURL: URL, - next: - @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> ( - HTTPResponse, HTTPBody? - ), - count: Int - ) async -> [Bool] { - let tasks = (1...count).map { _ in - Task { - await middleware.interceptWithMiddleware( - request: request, - baseURL: baseURL, - operationID: "test-operation", - next: next - ) - } - } - - return await withTaskGroup(of: Bool.self) { group in - for task in tasks { - group.addTask { await task.value } - } - - var results: [Bool] = [] - for await result in group { - results.append(result) - } - return results - } - } - - // MARK: - Error Scenario Tests - - /// Tests concurrent token refresh with refresh failures - @Test("Concurrent token refresh with refresh failures") - internal func concurrentTokenRefreshWithRefreshFailures() async throws { - let mockTokenManager = MockTokenManagerWithRefreshFailure() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let request = createTestRequest() - let next = createSuccessNextHandler() - let baseURL = URL.MistKit.cloudKitAPI - - // Test concurrent access with refresh failures - let results = await executeConcurrentMiddlewareCalls( - middleware: middleware, - request: request, - baseURL: baseURL, - next: next, - count: 3 - ) - - // At least one should fail due to refresh failure - let hasFailure = results.contains(false) - #expect(hasFailure) - - // Verify that refresh was attempted - #expect(await mockTokenManager.refreshCallCount > 0) - } - - /// Tests concurrent token refresh with timeout scenarios - @Test("Concurrent token refresh with timeout scenarios") - internal func concurrentTokenRefreshWithTimeoutScenarios() async throws { - let mockTokenManager = MockTokenManagerWithRefreshTimeout() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let request = createTestRequest() - let next = createSuccessNextHandler() - let baseURL = URL.MistKit.cloudKitAPI - - // Test concurrent access with timeout scenarios - let results = await executeConcurrentMiddlewareCalls( - middleware: middleware, - request: request, - baseURL: baseURL, - next: next, - count: 3 - ) - - // Results may vary due to timeout, but at least one should complete - let hasSuccess = results.contains(true) - #expect(hasSuccess) - - // Verify that refresh was attempted - #expect(await mockTokenManager.refreshCallCount > 0) - } -} diff --git a/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshPerformanceTests.swift b/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshPerformanceTests.swift deleted file mode 100644 index 19089934..00000000 --- a/Tests/MistKitTests/Storage/Concurrent/ConcurrentTokenRefresh/ConcurrentTokenRefreshPerformanceTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import HTTPTypes -import OpenAPIRuntime -import Testing - -@testable import MistKit - -@Suite("Concurrent Token Refresh Performance Tests") -/// Test suite for concurrent token refresh performance functionality -internal struct ConcurrentTokenRefreshPerformanceTests { - // MARK: - Helper Methods - - /// Creates a standard test request for concurrent token refresh tests - private func createTestRequest() -> HTTPRequest { - HTTPRequest( - method: .get, - scheme: "https", - authority: "api.apple-cloudkit.com", - path: "/database/1/iCloud.com.example.app/private/records/query" - ) - } - - /// Creates a standard next handler that returns success - private func createSuccessNextHandler() - -> @Sendable (HTTPRequest, HTTPBody?, URL) async throws - -> (HTTPResponse, HTTPBody?) - { - { _, _, _ in (HTTPResponse(status: .ok), nil) } - } - - /// Executes concurrent middleware calls and returns results - private func executeConcurrentMiddlewareCalls( - middleware: AuthenticationMiddleware, - request: HTTPRequest, - baseURL: URL, - next: - @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> ( - HTTPResponse, HTTPBody? - ), - count: Int - ) async -> [Bool] { - let tasks = (1...count).map { _ in - Task { - await middleware.interceptWithMiddleware( - request: request, - baseURL: baseURL, - operationID: "test-operation", - next: next - ) - } - } - - return await withTaskGroup(of: Bool.self) { group in - for task in tasks { - group.addTask { await task.value } - } - - var results: [Bool] = [] - for await result in group { - results.append(result) - } - return results - } - } - - // MARK: - Performance Scenario Tests - - /// Tests concurrent token refresh with rate limiting - @Test("Concurrent token refresh with rate limiting") - internal func concurrentTokenRefreshWithRateLimiting() async throws { - let mockTokenManager = MockTokenManagerWithRateLimiting() - let middleware = AuthenticationMiddleware(tokenManager: mockTokenManager) - - let request = createTestRequest() - let next = createSuccessNextHandler() - let baseURL = URL.MistKit.cloudKitAPI - - // Test concurrent access with rate limiting - let results = await executeConcurrentMiddlewareCalls( - middleware: middleware, - request: request, - baseURL: baseURL, - next: next, - count: 3 - ) - - // All should succeed eventually due to rate limiting handling - for result in results { - #expect(result == true) - } - - // Verify that refresh was called multiple times due to rate limiting - #expect(await mockTokenManager.refreshCallCount >= 3) - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorage+TestHelpers.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorage+TestHelpers.swift deleted file mode 100644 index 9b3dedfb..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorage+TestHelpers.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension InMemoryTokenStorage { - /// Test helper to store credentials and return a boolean result - internal func storeCredentials(_ credentials: TokenCredentials) async -> Bool { - do { - try await store(credentials, identifier: nil) - return true - } catch { - return false - } - } - - /// Test helper to get credentials by identifier - internal func getCredentials(identifier: String? = nil) async -> TokenCredentials? { - try? await retrieve(identifier: identifier) - } - - /// Test helper to store and retrieve credentials - internal func storeAndRetrieve(_ credentials: TokenCredentials) async -> Bool { - do { - try await store(credentials, identifier: nil) - let retrieved = try await retrieve(identifier: nil) - return retrieved != nil - } catch { - return false - } - } - - /// Test helper to remove token by identifier - internal func removeToken(identifier: String) async -> Bool { - do { - try await remove(identifier: identifier) - return true - } catch { - return false - } - } - - /// Test helper to get token by identifier - internal func getToken(identifier: String) async -> TokenCredentials? { - try? await retrieve(identifier: identifier) - } - - /// Test helper to store token with key and token string - internal func storeToken(key: String, token: String) async throws { - let credentials = TokenCredentials.apiToken(token) - try await store(credentials, identifier: key) - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageInitializationTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageInitializationTests.swift deleted file mode 100644 index 3994fdf7..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageInitializationTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("In-Memory Token Storage Initialization") -/// Test suite for InMemoryTokenStorage initialization and basic storage functionality -internal struct InMemoryTokenStorageInitializationTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let testWebAuthToken = "user123_web_auth_token_abcdef" - - // MARK: - Initialization Tests - - /// Tests InMemoryTokenStorage initialization - @Test("InMemoryTokenStorage initialization") - internal func initialization() { - let storage = InMemoryTokenStorage() - // Storage should be created successfully - _ = storage - } - - // MARK: - Token Storage Tests - - /// Tests storing API token - @Test("Store API token") - internal func storeAPIToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved != nil) - - if let retrieved = retrieved { - if case .apiToken(let token) = retrieved.method { - #expect(token == Self.testAPIToken) - } else { - Issue.record("Expected .apiToken method") - } - } - } - - /// Tests storing web auth token - @Test("Store web auth token") - internal func storeWebAuthToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.webAuthToken( - apiToken: Self.testAPIToken, - webToken: Self.testWebAuthToken - ) - - try await storage.store(credentials, identifier: nil) - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved != nil) - - if let retrieved = retrieved { - if case .webAuthToken(let api, let web) = retrieved.method { - #expect(api == Self.testAPIToken) - #expect(web == Self.testWebAuthToken) - } else { - Issue.record("Expected .webAuthToken method") - } - } - } - - /// Tests storing server-to-server credentials - @Test("Store server-to-server credentials") - internal func storeServerToServerCredentials() async throws { - let storage = InMemoryTokenStorage() - let keyID = "test-key-id-12345678" - let privateKeyData = Data([1, 2, 3, 4, 5]) - let credentials = TokenCredentials.serverToServer( - keyID: keyID, - privateKey: privateKeyData - ) - - try await storage.store(credentials, identifier: nil) - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved != nil) - - if let retrieved = retrieved { - if case .serverToServer(let storedKeyID, let storedPrivateKey) = retrieved.method { - #expect(storedKeyID == keyID) - #expect(storedPrivateKey == privateKeyData) - } else { - Issue.record("Expected .serverToServer method") - } - } - } - - /// Tests storing credentials with metadata - @Test("Store credentials with metadata") - internal func storeCredentialsWithMetadata() async throws { - let storage = InMemoryTokenStorage() - let metadata = ["created": "2025-01-01", "environment": "test"] - let credentials = TokenCredentials( - method: .apiToken(Self.testAPIToken), - metadata: metadata - ) - - try await storage.store(credentials, identifier: nil) - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved != nil) - - if let retrieved = retrieved { - #expect(retrieved.metadata["created"] == "2025-01-01") - #expect(retrieved.metadata["environment"] == "test") - } - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageReplacementTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageReplacementTests.swift deleted file mode 100644 index 6bdd743a..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageReplacementTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("In-Memory Token Storage Replacement") -/// Test suite for InMemoryTokenStorage token replacement functionality -internal struct InMemoryTokenStorageReplacementTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let testWebAuthToken = "user123_web_auth_token_abcdef" - - // MARK: - Token Replacement Tests - - /// Tests replacing stored token with new token - @Test("Replace stored token with new token") - internal func replaceStoredTokenWithNewToken() async throws { - let storage = InMemoryTokenStorage() - let originalCredentials = TokenCredentials.apiToken(Self.testAPIToken) - let newCredentials = TokenCredentials.webAuthToken( - apiToken: Self.testAPIToken, - webToken: Self.testWebAuthToken - ) - - try await storage.store(originalCredentials, identifier: nil) - - let retrievedBefore = try await storage.retrieve(identifier: nil) - #expect(retrievedBefore != nil) - - try await storage.store(newCredentials, identifier: nil) - - let retrievedAfter = try await storage.retrieve(identifier: nil) - #expect(retrievedAfter != nil) - #expect(retrievedAfter == newCredentials) - #expect(retrievedAfter != originalCredentials) - } - - /// Tests replacing stored token with same token - @Test("Replace stored token with same token") - internal func replaceStoredTokenWithSameToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - - let retrievedBefore = try await storage.retrieve(identifier: nil) - #expect(retrievedBefore != nil) - - try await storage.store(credentials, identifier: nil) - - let retrievedAfter = try await storage.retrieve(identifier: nil) - #expect(retrievedAfter != nil) - #expect(retrievedAfter == credentials) - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageRetrievalTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageRetrievalTests.swift deleted file mode 100644 index dc2fafe4..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageRetrievalTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -@Suite("In-Memory Token Storage Retrieval") -/// Test suite for InMemoryTokenStorage token retrieval and removal functionality -internal struct InMemoryTokenStorageRetrievalTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - // MARK: - Token Retrieval Tests - - /// Tests retrieving stored token - @Test("Retrieve stored token") - internal func retrieveStoredToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved != nil) - #expect(retrieved == credentials) - } - - /// Tests retrieving non-existent token - @Test("Retrieve non-existent token") - internal func retrieveNonExistentToken() async throws { - let storage = InMemoryTokenStorage() - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved == nil) - } - - /// Tests retrieving token after clearing storage - @Test("Retrieve token after clearing storage") - internal func retrieveTokenAfterClearingStorage() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - await storage.clear() - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved == nil) - } - - // MARK: - Token Removal Tests - - /// Tests removing stored token - @Test("Remove stored token") - internal func removeStoredToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - - let retrievedBefore = try await storage.retrieve(identifier: nil) - #expect(retrievedBefore != nil) - - try await storage.remove(identifier: nil) - - let retrievedAfter = try await storage.retrieve(identifier: nil) - #expect(retrievedAfter == nil) - } - - /// Tests removing non-existent token - @Test("Remove non-existent token") - internal func removeNonExistentToken() async throws { - let storage = InMemoryTokenStorage() - - // Should not throw or crash - try await storage.remove(identifier: nil) - - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved == nil) - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentRemovalTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentRemovalTests.swift deleted file mode 100644 index 909a3545..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentRemovalTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension InMemoryTokenStorageTests { - /// Concurrent removal tests for InMemoryTokenStorage - @Suite("Concurrent Removal Tests") - internal struct ConcurrentRemovalTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - // MARK: - Concurrent Removal Tests - - /// Tests concurrent token removal - @Test("Concurrent token removal") - internal func concurrentTokenRemoval() async throws { - let storage = InMemoryTokenStorage() - - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.apiToken("token2") - let credentials3 = TokenCredentials.apiToken("token3") - - // Store multiple tokens - try await storage.store(credentials1, identifier: "concurrent1") - try await storage.store(credentials2, identifier: "concurrent2") - try await storage.store(credentials3, identifier: "concurrent3") - - // Test concurrent removal - async let task1 = storage.removeToken(identifier: "concurrent1") - async let task2 = storage.removeToken(identifier: "concurrent2") - async let task3 = storage.removeToken(identifier: "concurrent3") - - let results = await (task1, task2, task3) - #expect(results.0 == true) - #expect(results.1 == true) - #expect(results.2 == true) - - // Verify all tokens are removed - let identifiers = try await storage.listIdentifiers() - #expect(!identifiers.contains("concurrent1")) - #expect(!identifiers.contains("concurrent2")) - #expect(!identifiers.contains("concurrent3")) - } - - /// Tests concurrent removal and retrieval - @Test("Concurrent removal and retrieval") - internal func concurrentRemovalAndRetrieval() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: "concurrent-test") - - // Test concurrent removal and retrieval - async let task1 = storage.removeToken(identifier: "concurrent-test") - async let task2 = storage.getToken(identifier: "concurrent-test") - async let task3 = storage.removeToken(identifier: "concurrent-test") - - let results = await (task1, task2, task3) - // At least removal should succeed - #expect(results.0 == true || results.2 == true) - - // Token should be removed - let retrieved = try await storage.retrieve(identifier: "concurrent-test") - #expect(retrieved == nil) - } - - // MARK: - Storage State Tests - - /// Tests storage state after removal - @Test("Storage state after removal") - internal func storageStateAfterRemoval() async throws { - let storage = InMemoryTokenStorage() - - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.apiToken("token2") - - // Store tokens - try await storage.store(credentials1, identifier: "state1") - try await storage.store(credentials2, identifier: "state2") - - // Verify storage is not empty - let isEmptyBefore = await storage.isEmpty - #expect(isEmptyBefore == false) - - let countBefore = await storage.count - #expect(countBefore == 2) - - // Remove one token - try await storage.remove(identifier: "state1") - - // Verify storage state - let isEmptyAfter = await storage.isEmpty - #expect(isEmptyAfter == false) - - let countAfter = await storage.count - #expect(countAfter == 1) - - // Remove remaining token - try await storage.remove(identifier: "state2") - - // Verify storage is empty - let isEmptyFinal = await storage.isEmpty - #expect(isEmptyFinal == true) - - let countFinal = await storage.count - #expect(countFinal == 0) - } - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentTests.swift deleted file mode 100644 index ed1103c2..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ConcurrentTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -internal enum InMemoryTokenStorageTests {} - -extension InMemoryTokenStorageTests { - /// Concurrent access tests for InMemoryTokenStorage - @Suite("Concurrent Tests") - internal struct ConcurrentTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - // MARK: - Concurrent Access Tests - - /// Tests concurrent storage operations - @Test("Concurrent storage operations") - internal func concurrentStorageOperations() async throws { - let storage = InMemoryTokenStorage() - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.apiToken("token2") - let credentials3 = TokenCredentials.apiToken("token3") - - // Test concurrent storage operations - async let task1 = storage.storeCredentials(credentials1) - async let task2 = storage.storeCredentials(credentials2) - async let task3 = storage.storeCredentials(credentials3) - - let results = await (task1, task2, task3) - #expect(results.0 == true) - #expect(results.1 == true) - #expect(results.2 == true) - - // Verify that one of the credentials was stored - let retrieved = try await storage.retrieve(identifier: nil) - #expect(retrieved != nil) - } - - /// Tests concurrent retrieval operations - @Test("Concurrent retrieval operations") - internal func concurrentRetrievalOperations() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - - // Test concurrent retrieval operations - async let task1 = storage.getCredentials() - async let task2 = storage.getCredentials() - async let task3 = storage.getCredentials() - - let results = await (task1, task2, task3) - #expect(results.0 != nil) - #expect(results.1 != nil) - #expect(results.2 != nil) - - // All should return the same credentials - #expect(results.0 == results.1) - #expect(results.1 == results.2) - } - - // MARK: - Sendable Compliance Tests - - /// Tests that InMemoryTokenStorage can be used across async boundaries - @Test("InMemoryTokenStorage sendable compliance") - internal func sendableCompliance() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - // Test concurrent access patterns - async let task1 = storage.storeAndRetrieve(credentials) - async let task2 = storage.storeAndRetrieve(credentials) - async let task3 = storage.storeAndRetrieve(credentials) - - let results = await (task1, task2, task3) - #expect(results.0 == true) - #expect(results.1 == true) - #expect(results.2 == true) - } - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ExpirationTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ExpirationTests.swift deleted file mode 100644 index 89602925..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+ExpirationTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension InMemoryTokenStorageTests { - /// Expiration handling tests for InMemoryTokenStorage - @Suite("Expiration Tests") - internal struct ExpirationTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - - // MARK: - Token Expiration Tests - - /// Tests storing token with expiration time - @Test("Store token with expiration time") - internal func storeTokenWithExpirationTime() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let expirationTime = Date().addingTimeInterval(3_600) // 1 hour from now - - try await storage.store(credentials, identifier: "test", expirationTime: expirationTime) - - let retrieved = try await storage.retrieve(identifier: "test") - #expect(retrieved != nil) - - if let retrieved = retrieved { - if case .apiToken(let token) = retrieved.method { - #expect(token == Self.testAPIToken) - } else { - Issue.record("Expected .apiToken method") - } - } - } - - /// Tests retrieving expired token - @Test("Retrieve expired token") - internal func retrieveExpiredToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let expirationTime = Date().addingTimeInterval(-3_600) // 1 hour ago (expired) - - try await storage.store(credentials, identifier: "expired", expirationTime: expirationTime) - - let retrieved = try await storage.retrieve(identifier: "expired") - #expect(retrieved == nil) // Should be nil because token is expired - } - - /// Tests retrieving non-expired token - @Test("Retrieve non-expired token") - internal func retrieveNonExpiredToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let expirationTime = Date().addingTimeInterval(3_600) // 1 hour from now - - try await storage.store(credentials, identifier: "valid", expirationTime: expirationTime) - - let retrieved = try await storage.retrieve(identifier: "valid") - #expect(retrieved != nil) // Should not be nil because token is not expired - } - - /// Tests storing token without expiration time - @Test("Store token without expiration time") - internal func storeTokenWithoutExpirationTime() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: "no-expiry", expirationTime: nil) - - let retrieved = try await storage.retrieve(identifier: "no-expiry") - #expect(retrieved != nil) // Should not be nil because no expiration time - } - - /// Tests token expiration cleanup - @Test("Token expiration cleanup") - internal func tokenExpirationCleanup() async throws { - let storage = InMemoryTokenStorage() - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.apiToken("token2") - let credentials3 = TokenCredentials.apiToken("token3") - - // Store tokens with different expiration times - try await storage.store( - credentials1, - identifier: "expired1", - expirationTime: Date().addingTimeInterval(-3_600) - ) - try await storage.store( - credentials2, - identifier: "expired2", - expirationTime: Date().addingTimeInterval(-1_800) - ) - try await storage.store( - credentials3, - identifier: "valid", - expirationTime: Date().addingTimeInterval(3_600) - ) - - // Verify all tokens are initially stored - let identifiersBefore = try await storage.listIdentifiers() - #expect(identifiersBefore.count == 3) - - // Clean up expired tokens - await storage.cleanupExpiredTokens() - - // Verify only non-expired token remains - let identifiersAfter = try await storage.listIdentifiers() - #expect(identifiersAfter.count == 1) - #expect(identifiersAfter.contains("valid")) - - // Verify expired tokens are gone - let retrievedExpired1 = try await storage.retrieve(identifier: "expired1") - let retrievedExpired2 = try await storage.retrieve(identifier: "expired2") - let retrievedValid = try await storage.retrieve(identifier: "valid") - - #expect(retrievedExpired1 == nil) - #expect(retrievedExpired2 == nil) - #expect(retrievedValid != nil) - } - - /// Tests automatic expiration during retrieval - @Test("Automatic expiration during retrieval") - internal func automaticExpirationDuringRetrieval() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let expirationTime = Date().addingTimeInterval(-1) // Just expired - - try await storage.store( - credentials, - identifier: "auto-expired", - expirationTime: expirationTime - ) - - // First retrieval should return nil due to expiration - let retrieved = try await storage.retrieve(identifier: "auto-expired") - #expect(retrieved == nil) - - // Token should be automatically removed from storage - let identifiers = try await storage.listIdentifiers() - #expect(!identifiers.contains("auto-expired")) - } - - /// Tests storing multiple tokens with different expiration times - @Test("Store multiple tokens with different expiration times") - internal func storeMultipleTokensWithDifferentExpirationTimes() async throws { - let storage = InMemoryTokenStorage() - let now = Date() - - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.apiToken("token2") - let credentials3 = TokenCredentials.apiToken("token3") - - // Store tokens with different expiration times - try await storage.store( - credentials1, - identifier: "short", - expirationTime: now.addingTimeInterval(60) - ) // 1 minute - try await storage.store( - credentials2, - identifier: "medium", - expirationTime: now.addingTimeInterval(3_600) - ) // 1 hour - try await storage.store( - credentials3, - identifier: "long", - expirationTime: now.addingTimeInterval(86_400) - ) // 1 day - - // All should be retrievable initially - let retrieved1 = try await storage.retrieve(identifier: "short") - let retrieved2 = try await storage.retrieve(identifier: "medium") - let retrieved3 = try await storage.retrieve(identifier: "long") - - #expect(retrieved1 != nil) - #expect(retrieved2 != nil) - #expect(retrieved3 != nil) - } - - /// Tests expiration time edge cases - @Test("Expiration time edge cases") - internal func expirationTimeEdgeCases() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - // Test with expiration time exactly at current time - let exactExpiration = Date() - try await storage.store(credentials, identifier: "exact", expirationTime: exactExpiration) - - let retrieved = try await storage.retrieve(identifier: "exact") - #expect(retrieved == nil) // Should be expired - } - - /// Tests concurrent access with expiration - @Test("Concurrent access with expiration") - internal func concurrentAccessWithExpiration() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let expirationTime = Date().addingTimeInterval(3_600) // 1 hour from now - - try await storage.store(credentials, identifier: "concurrent", expirationTime: expirationTime) - - // Test concurrent retrieval of non-expired token - async let task1 = storage.getCredentials(identifier: "concurrent") - async let task2 = storage.getCredentials(identifier: "concurrent") - async let task3 = storage.getCredentials(identifier: "concurrent") - - let results = await (task1, task2, task3) - #expect(results.0 != nil) - #expect(results.1 != nil) - #expect(results.2 != nil) - } - } -} diff --git a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+RemovalTests.swift b/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+RemovalTests.swift deleted file mode 100644 index e76f28d3..00000000 --- a/Tests/MistKitTests/Storage/InMemory/InMemoryTokenStorage/InMemoryTokenStorageTests+RemovalTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -import Foundation -import Testing - -@testable import MistKit - -extension InMemoryTokenStorageTests { - /// Token removal tests for InMemoryTokenStorage - @Suite("Removal Tests") - internal struct RemovalTests { - // MARK: - Test Data Setup - - private static let testAPIToken = - "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" - private static let testWebAuthToken = "user123_web_auth_token_abcdef" - - // MARK: - Basic Removal Tests - - /// Tests removing stored token by identifier - @Test("Remove stored token by identifier") - internal func removeStoredTokenByIdentifier() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: "test-token") - - let retrievedBefore = try await storage.retrieve(identifier: "test-token") - #expect(retrievedBefore != nil) - - try await storage.remove(identifier: "test-token") - - let retrievedAfter = try await storage.retrieve(identifier: "test-token") - #expect(retrievedAfter == nil) - } - - /// Tests removing non-existent token - @Test("Remove non-existent token") - internal func removeNonExistentToken() async throws { - let storage = InMemoryTokenStorage() - - // Should not throw or crash - try await storage.remove(identifier: "non-existent") - - let retrieved = try await storage.retrieve(identifier: "non-existent") - #expect(retrieved == nil) - } - - /// Tests removing token with nil identifier - @Test("Remove token with nil identifier") - internal func removeTokenWithNilIdentifier() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: nil) - - let retrievedBefore = try await storage.retrieve(identifier: nil) - #expect(retrievedBefore != nil) - - try await storage.remove(identifier: nil) - - let retrievedAfter = try await storage.retrieve(identifier: nil) - #expect(retrievedAfter == nil) - } - - // MARK: - Multiple Token Removal Tests - - /// Tests removing specific token from multiple stored tokens - @Test("Remove specific token from multiple stored tokens") - internal func removeSpecificTokenFromMultipleStoredTokens() async throws { - let storage = InMemoryTokenStorage() - - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.webAuthToken( - apiToken: Self.testAPIToken, - webToken: Self.testWebAuthToken - ) - let credentials3 = TokenCredentials.apiToken("token3") - - // Store multiple tokens - try await storage.store(credentials1, identifier: "api1") - try await storage.store(credentials2, identifier: "web") - try await storage.store(credentials3, identifier: "api3") - - // Verify all tokens are stored - let identifiersBefore = try await storage.listIdentifiers() - #expect(identifiersBefore.count == 3) - - // Remove specific token - try await storage.remove(identifier: "web") - - // Verify only specific token is removed - let identifiersAfter = try await storage.listIdentifiers() - #expect(identifiersAfter.count == 2) - #expect(identifiersAfter.contains("api1")) - #expect(identifiersAfter.contains("api3")) - #expect(!identifiersAfter.contains("web")) - - // Verify removed token is gone - let retrievedWeb = try await storage.retrieve(identifier: "web") - #expect(retrievedWeb == nil) - - // Verify other tokens remain - let retrievedApi1 = try await storage.retrieve(identifier: "api1") - let retrievedApi3 = try await storage.retrieve(identifier: "api3") - #expect(retrievedApi1 != nil) - #expect(retrievedApi3 != nil) - } - - /// Tests removing all tokens by clearing storage - @Test("Remove all tokens by clearing storage") - internal func removeAllTokensByClearingStorage() async throws { - let storage = InMemoryTokenStorage() - - let credentials1 = TokenCredentials.apiToken("token1") - let credentials2 = TokenCredentials.webAuthToken( - apiToken: Self.testAPIToken, - webToken: Self.testWebAuthToken - ) - let credentials3 = TokenCredentials.apiToken("token3") - - // Store multiple tokens - try await storage.store(credentials1, identifier: "api1") - try await storage.store(credentials2, identifier: "web") - try await storage.store(credentials3, identifier: "api3") - - // Verify all tokens are stored - let identifiersBefore = try await storage.listIdentifiers() - #expect(identifiersBefore.count == 3) - - // Clear all tokens - await storage.clear() - - // Verify all tokens are removed - let identifiersAfter = try await storage.listIdentifiers() - #expect(identifiersAfter.isEmpty) - - // Verify all tokens are gone - let retrievedApi1 = try await storage.retrieve(identifier: "api1") - let retrievedWeb = try await storage.retrieve(identifier: "web") - let retrievedApi3 = try await storage.retrieve(identifier: "api3") - #expect(retrievedApi1 == nil) - #expect(retrievedWeb == nil) - #expect(retrievedApi3 == nil) - } - - // MARK: - Edge Case Removal Tests - - /// Tests removing token with empty string identifier - @Test("Remove token with empty string identifier") - internal func removeTokenWithEmptyStringIdentifier() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - - try await storage.store(credentials, identifier: "") - - let retrievedBefore = try await storage.retrieve(identifier: "") - #expect(retrievedBefore != nil) - - try await storage.remove(identifier: "") - - let retrievedAfter = try await storage.retrieve(identifier: "") - #expect(retrievedAfter == nil) - } - - /// Tests removing token with special characters in identifier - @Test("Remove token with special characters in identifier") - internal func removeTokenWithSpecialCharactersInIdentifier() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let specialIdentifier = "test@#$%^&*()_+-={}[]|\\:;\"'<>?,./" - - try await storage.store(credentials, identifier: specialIdentifier) - - let retrievedBefore = try await storage.retrieve(identifier: specialIdentifier) - #expect(retrievedBefore != nil) - - try await storage.remove(identifier: specialIdentifier) - - let retrievedAfter = try await storage.retrieve(identifier: specialIdentifier) - #expect(retrievedAfter == nil) - } - - /// Tests removing expired token - @Test("Remove expired token") - internal func removeExpiredToken() async throws { - let storage = InMemoryTokenStorage() - let credentials = TokenCredentials.apiToken(Self.testAPIToken) - let expirationTime = Date().addingTimeInterval(-3_600) // 1 hour ago (expired) - - try await storage.store(credentials, identifier: "expired", expirationTime: expirationTime) - - // Token should already be expired and not retrievable - let retrievedBefore = try await storage.retrieve(identifier: "expired") - #expect(retrievedBefore == nil) - - // Remove should still work even though token is expired - try await storage.remove(identifier: "expired") - - let retrievedAfter = try await storage.retrieve(identifier: "expired") - #expect(retrievedAfter == nil) - } - } -} diff --git a/Tests/MistKitTests/TestConstants.swift b/Tests/MistKitTests/TestConstants.swift new file mode 100644 index 00000000..23f5a517 --- /dev/null +++ b/Tests/MistKitTests/TestConstants.swift @@ -0,0 +1,53 @@ +// +// TestConstants.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Shared constants used across the MistKit test suite. +/// +/// Centralizes magic strings that previously appeared verbatim in many test files: +/// API tokens, web-auth tokens, container identifiers, zone names, and operation IDs. +internal enum TestConstants { + /// 64-character hexadecimal API token in the format MistKit's regex validation expects. + internal static let apiToken = + "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" + + /// Sample web-auth token used by middleware and client tests. + /// + /// Length and character set satisfy `webAuthTokenRegex` + /// (`^[A-Za-z0-9+/=_]{100,}$`) so tests remain valid if regex-based + /// validation is later added to `WebAuthTokenManager.validateCredentials()`. + internal static let webAuthToken = + "user123webauthtokenabcdef0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789AB==" + + /// Container identifier used by `CloudKitService` integration-style tests. + internal static let serviceContainerIdentifier = "iCloud.com.example.test" + + /// Default operation ID used in middleware intercept tests. + internal static let operationID = "test-operation" +} diff --git a/Tests/MistKitTests/Utilities/ArrayChunkedTests.swift b/Tests/MistKitTests/Utilities/ArrayChunkedTests.swift deleted file mode 100644 index 74a18d24..00000000 --- a/Tests/MistKitTests/Utilities/ArrayChunkedTests.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// ArrayChunkedTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("Array Chunked Tests") -internal struct ArrayChunkedTests { - @Test("chunked splits array into correct chunks") - internal func chunkedSplitsCorrectly() { - let array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - let chunks = array.chunked(into: 3) - - #expect(chunks.count == 4) - #expect(chunks[0] == [1, 2, 3]) - #expect(chunks[1] == [4, 5, 6]) - #expect(chunks[2] == [7, 8, 9]) - #expect(chunks[3] == [10]) - } - - @Test("chunked handles exact multiple of chunk size") - internal func chunkedExactMultiple() { - let array = [1, 2, 3, 4, 5, 6] - let chunks = array.chunked(into: 2) - - #expect(chunks.count == 3) - #expect(chunks[0] == [1, 2]) - #expect(chunks[1] == [3, 4]) - #expect(chunks[2] == [5, 6]) - } - - @Test("chunked handles remainder elements") - internal func chunkedWithRemainder() { - let array = [1, 2, 3, 4, 5] - let chunks = array.chunked(into: 2) - - #expect(chunks.count == 3) - #expect(chunks[0] == [1, 2]) - #expect(chunks[1] == [3, 4]) - #expect(chunks[2] == [5]) - } - - @Test("chunked handles empty array") - internal func chunkedEmptyArray() { - let array: [Int] = [] - let chunks = array.chunked(into: 5) - - #expect(chunks.isEmpty) - } - - @Test("chunked handles single element") - internal func chunkedSingleElement() { - let array = [42] - let chunks = array.chunked(into: 5) - - #expect(chunks.count == 1) - #expect(chunks[0] == [42]) - } - - @Test("chunked handles chunk size larger than array") - internal func chunkedLargerChunkSize() { - let array = [1, 2, 3] - let chunks = array.chunked(into: 10) - - #expect(chunks.count == 1) - #expect(chunks[0] == [1, 2, 3]) - } - - @Test("chunked respects CloudKit 200-item limit", arguments: [200, 199, 201, 400, 600]) - internal func chunkedCloudKitLimit(totalItems: Int) { - let array = Array(1...totalItems) - let chunks = array.chunked(into: 200) - - // Verify all chunks except last are exactly 200 - for (index, chunk) in chunks.enumerated() { - if index < chunks.count - 1 { - #expect(chunk.count == 200) - } else { - // Last chunk can be <= 200 - #expect(chunk.count <= 200) - } - } - - // Verify we didn't lose any elements - let totalElements = chunks.flatMap { $0 }.count - #expect(totalElements == totalItems) - } - - @Test("chunked with chunk size 1") - internal func chunkedSizeOne() { - let array = [1, 2, 3, 4, 5] - let chunks = array.chunked(into: 1) - - #expect(chunks.count == 5) - for (index, chunk) in chunks.enumerated() { - #expect(chunk == [index + 1]) - } - } - - @Test("chunked preserves element order") - internal func chunkedPreservesOrder() { - let array = ["a", "b", "c", "d", "e", "f", "g"] - let chunks = array.chunked(into: 3) - - let flattened = chunks.flatMap { $0 } - #expect(flattened == array) - } - - @Test("chunked with different element types") - internal func chunkedDifferentTypes() { - struct TestItem: Equatable { - let id: Int - let name: String - } - - let items = [ - TestItem(id: 1, name: "a"), - TestItem(id: 2, name: "b"), - TestItem(id: 3, name: "c"), - TestItem(id: 4, name: "d"), - ] - - let chunks = items.chunked(into: 2) - - #expect(chunks.count == 2) - #expect(chunks[0].count == 2) - #expect(chunks[1].count == 2) - #expect(chunks[0][0].id == 1) - #expect(chunks[1][0].id == 3) - } - - @Test("chunked large array performance") - internal func chunkedLargeArray() { - let array = Array(1...10_000) - let chunks = array.chunked(into: 200) - - #expect(chunks.count == 50) - #expect(chunks.allSatisfy { $0.count <= 200 }) - - let totalElements = chunks.flatMap { $0 }.count - #expect(totalElements == 10_000) - } - - @Test("chunked with various CloudKit batch sizes", arguments: [50, 100, 150, 200, 250]) - internal func chunkedVariousBatchSizes(batchSize: Int) { - let array = Array(1...1_000) - let chunks = array.chunked(into: batchSize) - - // Verify no chunk exceeds batch size - #expect(chunks.allSatisfy { $0.count <= batchSize }) - - // Verify we didn't lose any elements - let totalElements = chunks.flatMap { $0 }.count - #expect(totalElements == 1_000) - - // Verify all chunks except last are full - for (index, chunk) in chunks.enumerated() where index < chunks.count - 1 { - #expect(chunk.count == batchSize) - } - } -} diff --git a/Tests/MistKitTests/Utilities/RegexPatternsTests+Convenience.swift b/Tests/MistKitTests/Utilities/RegexPatternsTests+Convenience.swift deleted file mode 100644 index c84fde3a..00000000 --- a/Tests/MistKitTests/Utilities/RegexPatternsTests+Convenience.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// RegexPatternsTests+Convenience.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension RegexPatternsTests { - // MARK: - Convenience Method Tests - - @Test("matches(in:) convenience method works correctly") - internal func convenienceMatchesMethod() { - let token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - let matches = NSRegularExpression.apiTokenRegex.matches(in: token) - - #expect(matches.count == 1) - #expect(matches[0].range.length == 64) - } - - @Test("matches(in:) handles empty string") - internal func convenienceMatchesEmptyString() { - let matches = NSRegularExpression.apiTokenRegex.matches(in: "") - #expect(matches.isEmpty) - } - - @Test("matches(in:) handles unicode strings") - internal func convenienceMatchesUnicode() { - let text = "Hello 🌍 token=abc123" - let matches = NSRegularExpression.maskGenericTokenRegex.matches(in: text) - #expect(matches.count >= 1) - } - - // MARK: - Edge Cases - - @Test("Multiple tokens in same string") - internal func multipleTokensInString() { - let token1 = String(repeating: "a", count: 64) - let token2 = String(repeating: "b", count: 64) - let text = "First: \(token1) Second: \(token2)" - - let matches = NSRegularExpression.maskApiTokenRegex.matches(in: text) - #expect(matches.count == 2) - } - - @Test("Overlapping patterns don't double-match") - internal func overlappingPatterns() { - let text = "keytoken=value123" - let keyMatches = NSRegularExpression.maskGenericKeyRegex.matches(in: text) - let tokenMatches = NSRegularExpression.maskGenericTokenRegex.matches(in: text) - - // Should find one or the other, not both - #expect((keyMatches.count + tokenMatches.count) > 0) - } - - @Test("Case sensitivity for hex patterns") - internal func caseSensitivityHex() { - let lowerCase = String(repeating: "a", count: 64) - let upperCase = String(repeating: "A", count: 64) - let mixed = (String(repeating: "a", count: 32) + String(repeating: "A", count: 32)) - - for token in [lowerCase, upperCase, mixed] { - let matches = NSRegularExpression.apiTokenRegex.matches(in: token) - #expect(matches.count == 1, "Should match hex regardless of case") - } - } -} diff --git a/Tests/MistKitTests/Utilities/RegexPatternsTests.swift b/Tests/MistKitTests/Utilities/RegexPatternsTests.swift deleted file mode 100644 index 0315279c..00000000 --- a/Tests/MistKitTests/Utilities/RegexPatternsTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// RegexPatternsTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -@Suite("NSRegularExpression CommonPatterns Tests") -internal struct RegexPatternsTests { - // MARK: - API Token Validation Tests - - @Test("API token regex validates correct 64-character hex strings") - internal func apiTokenValidHex() { - let validTokens = [ - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", - "0000000000000000000000000000000000000000000000000000000000000000", - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ] - - for token in validTokens { - let matches = NSRegularExpression.apiTokenRegex.matches(in: token) - #expect(matches.count == 1, "Should match valid API token: \(token)") - } - } - - @Test("API token regex rejects invalid formats") - internal func apiTokenInvalidFormats() { - let invalidTokens = [ - "abc", // Too short - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678", // 63 chars - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567890", // 65 chars - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678g", // Invalid char - "abcdef0123456789 abcdef0123456789abcdef0123456789abcdef0123456789", // Space - "", // Empty - ] - - for token in invalidTokens { - let matches = NSRegularExpression.apiTokenRegex.matches(in: token) - #expect(matches.isEmpty, "Should not match invalid API token: \(token)") - } - } - - // MARK: - Web Auth Token Validation Tests - - @Test("Web auth token regex validates base64-like strings") - internal func webAuthTokenValidBase64() { - let validTokens = [ - String(repeating: "A", count: 100), - String(repeating: "a", count: 150), - String(repeating: "0", count: 100) + "==", - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" - + String(repeating: "A", count: 40), - String(repeating: "Z", count: 200) + "_", - ] - - for token in validTokens { - let matches = NSRegularExpression.webAuthTokenRegex.matches(in: token) - #expect(matches.count == 1, "Should match valid web auth token") - } - } - - @Test("Web auth token regex rejects invalid formats") - internal func webAuthTokenInvalidFormats() { - let invalidTokens = [ - String(repeating: "A", count: 99), // Too short - "invalid chars !@#$%", - "", - "abc", - String(repeating: " ", count: 100), // Spaces not allowed - ] - - for token in invalidTokens { - let matches = NSRegularExpression.webAuthTokenRegex.matches(in: token) - #expect(matches.isEmpty, "Should not match invalid web auth token: \(token)") - } - } - - // MARK: - Key ID Validation Tests - - @Test("Key ID regex validates 64-character hex strings") - internal func keyIDValidHex() { - let validKeyIDs = [ - "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321", - "0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdefABCD", - ] - - for keyID in validKeyIDs { - let matches = NSRegularExpression.keyIDRegex.matches(in: keyID) - #expect(matches.count == 1, "Should match valid key ID: \(keyID)") - } - } - - @Test("Key ID regex rejects invalid formats") - internal func keyIDInvalidFormats() { - let invalidKeyIDs = [ - String(repeating: "a", count: 63), // Too short - String(repeating: "a", count: 65), // Too long - "g" + String(repeating: "a", count: 63), // Invalid character - "", - "key-id-with-dashes", - ] - - for keyID in invalidKeyIDs { - let matches = NSRegularExpression.keyIDRegex.matches(in: keyID) - #expect(matches.isEmpty, "Should not match invalid key ID: \(keyID)") - } - } - - // MARK: - Masking Pattern Tests - - @Test("Mask API token regex finds tokens in text") - internal func maskAPITokenFindsTokens() { - let text = "API token: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 found" - let matches = NSRegularExpression.maskApiTokenRegex.matches(in: text) - - #expect(matches.count == 1) - if let match = matches.first { - let range = match.range - let matchedText = (text as NSString).substring(with: range) - #expect(matchedText.count == 64) - } - } - - @Test("Mask web auth token regex finds tokens in text") - internal func maskWebAuthTokenFindsTokens() { - let token = String(repeating: "A", count: 100) - let text = "Web auth: \(token)== in message" - let matches = NSRegularExpression.maskWebAuthTokenRegex.matches(in: text) - - #expect(matches.count >= 1) - } - - @Test("Mask key ID regex finds key IDs in text") - internal func maskKeyIDFindsKeys() { - let keyID = String(repeating: "a", count: 40) - let text = "Key ID is \(keyID) here" - let matches = NSRegularExpression.maskKeyIdRegex.matches(in: text) - - #expect(matches.count == 1) - } - - @Test("Mask generic token regex finds token patterns") - internal func maskGenericTokenFindsPatterns() { - let testCases = [ - "token=abc123def456", - "token: xyz789", - "token=BASE64STRING==", - "token: BASE64+/==", - ] - - for text in testCases { - let matches = NSRegularExpression.maskGenericTokenRegex.matches(in: text) - #expect(matches.count >= 1, "Should find token in: \(text)") - } - } - - @Test("Mask generic key regex finds key patterns") - internal func maskGenericKeyFindsPatterns() { - let testCases = [ - "key=secretvalue123", - "key: privatekey456", - "key=KEYDATA789", - "key:KEY+DATA/123", - ] - - for text in testCases { - let matches = NSRegularExpression.maskGenericKeyRegex.matches(in: text) - #expect(matches.count >= 1, "Should find key in: \(text)") - } - } - - @Test("Mask generic secret regex finds secret patterns") - internal func maskGenericSecretFindsPatterns() { - let testCases = [ - "secret=mysecret123", - "secret: topsecret456", - "secret=CLASSIFIED789", - "secret:SECRET+VALUE/=", - ] - - for text in testCases { - let matches = NSRegularExpression.maskGenericSecretRegex.matches(in: text) - #expect(matches.count >= 1, "Should find secret in: \(text)") - } - } -} diff --git a/codecov.yml b/codecov.yml index 951b97b9..9605e17f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,2 +1,3 @@ ignore: - "Tests" + - "Sources/MistKit/Generated" diff --git a/docs/README.md b/docs/README.md index 13c208bf..d8ba84e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,4 @@ # Documentation - **[cloudkit-guide/](cloudkit-guide/)** — Content reference, talk prep, and marketing materials for the server-side CloudKit speaking series +- **[why-mistkit.md](why-mistkit.md)** — Use-case catalog of server-side CloudKit patterns (public database, private database, web app bridge, data aggregation) diff --git a/docs/cloudkit-guide/README.md b/docs/cloudkit-guide/README.md index 88931d24..3f81f447 100644 --- a/docs/cloudkit-guide/README.md +++ b/docs/cloudkit-guide/README.md @@ -1,4 +1,38 @@ -# MistKit Content & Talks +# CloudKit as Your Backend: From iOS to Server-Side Swift + +## Presentation Description from Swift Craft 2026 + +> CloudKit is great for iOS apps. How about backend services? I rebuilt a production CloudKit library and learned the patterns Apple doesn't document: three auth methods, type safety, error handling. Real deployments. Learn the whys and hows of using CloudKit on the backend. +> CloudKit has excellent documentation for iOS and macOS client development. But backend services—podcast aggregation, RSS readers, data processing—face APIs that Apple barely documents. I rebuilt a comprehensive CloudKit library using AI-generated OpenAPI specifications. The result: type-safe Swift code supporting three authentication methods (server-to-server, web authentication token, and API token), typed error handling for 9 HTTP status codes, and production deployments. + +> This talk fills the gaps with real production patterns: + +> Three Authentication Methods: - Server-to-Server: Autonomous services (podcast aggregation, cron jobs) - Web Authentication Token: User operations from backend (on behalf of signed-in users) - API Token: Development and debugging (CloudKit Dashboard) + +> Each method includes key generation, request signing, token handling, and failure recovery that Apple's documentation glosses over. You'll learn when to use each method and how to implement them with a unified ClientMiddleware pattern. + +> Type System Challenges: Solving CloudKit's dynamically-typed fields in Swift's statically-typed system with discriminated unions and type-safe record builders. + +> Production Error Handling: CloudKit returns 9+ HTTP status codes. Implementing typed error hierarchies, retry logic for transient failures, conflict resolution for concurrent modifications. + +> When to Use CloudKit: Decision framework comparing CloudKit vs. Firebase vs. custom backends with real production examples. + +> Drawing from production deployments (podcast backend, RSS sync service), attendees at all experience levels learn authentication patterns, type safety, error handling, and informed backend decisions. No prior CloudKit server-side experience required. + +--- + +## Core Narrative & Hook + +**Opening hook** (works for talks, videos, threads): +> "Raise your hand if you've used CloudKit from an iOS app. Keep it up if you've used CloudKit from a backend service. Yeah, that's the problem." + +**The problem**: CloudKit server-side is Apple's worst-documented feature. 2016-era docs. Auth barely explained. No error handling examples. Type system challenges unaddressed. Stack Overflow full of unanswered questions. + +**The solution story**: Built two production backends (BushelCloud + CelestraCloud) that required solving all of this. Then rebuilt MistKit from scratch using AI-generated OpenAPI specs to give others the patterns Apple didn't document. + +**Key insight on AI**: AI excels at documentation→OpenAPI spec translation. Human expertise required for architecture, error patterns, and API design. + +--- Educational content, reference material, and talk prep for an ongoing series about [MistKit](../README.md) and server-side CloudKit — covering what Apple's documentation leaves out. @@ -13,22 +47,61 @@ CloudKit Web Services is a REST API that works on any platform: server-side Swif --- -## Core Narrative & Hook +## Outline -**Opening hook** (works for talks, videos, threads): -> "Raise your hand if you've used CloudKit from an iOS app. Keep it up if you've used CloudKit from a backend service. Yeah, that's the problem." +### Why CloudKit -**The problem**: CloudKit server-side is Apple's worst-documented feature. 2016-era docs. Auth barely explained. No error handling examples. Type system challenges unaddressed. Stack Overflow full of unanswered questions. +#### iOS App 101 -**The solution story**: Built two production backends (BushelCloud + CelestraCloud) that required solving all of this. Then rebuilt MistKit from scratch using AI-generated OpenAPI specs to give others the patterns Apple didn't document. +#### CloudKit on the Server -**Key insight on AI**: AI excels at documentation→OpenAPI spec translation. Human expertise required for architecture, error patterns, and API design. +##### Why CloudKit on the Server + +* Web Application +* Background Job + +**Production Examples**: + +| App | Purpose | Auth | Real Challenges | +|---|---|---|---| +| BushelCloud | Syncs macOS/Swift/Xcode version data for Bushel VM | Server-to-server | Concurrent updates from multiple version sources | +| CelestraCloud | Syncs RSS feeds for Celestra RSS reader | Server-to-server | 15-min polling, aggressive rate limiting, conflict resolution | + +**Stats for credibility**: +- **MistKit**: actively maintained open-source library — see the [repo](../../) for current stats +- Built using AI-assisted OpenAPI generation — significantly faster than manual implementation +- **BushelCloud** and **CelestraCloud** are production deployments, each requiring substantial schema migrations + +**When to Use CloudKit**: + +Use CloudKit when: +- Building backend for an iOS/macOS app +- Data sync for indie/small team +- Zero server management preferred +- Already in the Apple ecosystem + +Consider alternatives when: +- Android support needed → Firebase +- Complex relational queries → PostgreSQL/Supabase +- Real-time updates → Firebase +- Full backend control → Vapor/Hummingbird + +**Reality check**: CloudKit's "free" tier has limits. Rate limiting (429) is real at scale. Factor in discovery time for undocumented auth patterns. --- -## Key Technical Topics +##### Understanding CloudKit + +| Theme | What It Covers | +|---|---| +| **Server-to-Server Auth** | Key pair generation, ECDSA request signing, credential lifecycle, what Apple's docs omit | +| **Type Safety** | CloudKit's dynamic fields vs. Swift's static types — discriminated unions, OpenAPI `oneOf` | +| **Error Handling** | 9 HTTP status codes, retry logic, exponential backoff, conflict resolution | +| **API Ergonomics** | Three-layer architecture: generated OpenAPI → abstraction → user-facing Swift API *(see Integrating MistKit)* | + +###### Authentication -### 1. Three Authentication Methods +**Three Authentication Methods**: | Method | Use Case | Status | |---|---|---| @@ -50,7 +123,54 @@ CloudKit Web Services is a REST API that works on any platform: server-side Swif --- -### 2. Type System Polymorphism +###### Database Scopes (Public vs Private vs Shared) + +All three databases use the same URL structure — swap the `{database}` path segment: + +``` +/database/1/{container}/{environment}/{public|private|shared}/{operation} +``` + +**Authentication per database**: + +| | Public | Private | Shared | +|---|---|---|---| +| **Server-to-Server Key** | Yes | No | No | +| **API Token (no user auth)** | Read only | No | No | +| **API Token + Web Auth Token** | Full access | Full access | Full access | + +**Operations availability**: + +| Operation | Public | Private | Shared | +|---|---|---|---| +| Query records | Yes | Yes | Yes | +| Lookup records | Yes | Yes | Yes | +| Modify records | Yes (requires auth) | Yes | Yes (if write permission) | +| Fetch record changes | No | Yes (custom zones only) | Yes | +| Custom zones | Not supported | Yes | Yes (owned by sharing user) | +| Create/modify zones | No | Yes | No | +| Zone changes | No | Yes | Yes | +| Zone-based subscriptions | No | Yes | Yes | +| Query-based subscriptions | Yes | Yes | No | +| Asset upload | Yes (requires auth) | Yes | Yes (if write access) | + +**Storage & access model**: + +| | Public | Private | Shared | +|---|---|---|---| +| **Storage** | App's iCloud allotment | User's iCloud account | Sharing owner's account | +| **Access model** | Security roles (world, authenticated, creator) | Owner only | Share participants | +| **Read without auth** | Yes (if role = world) | No | No | + +**Implications for server-to-server (MistKit backend services)**: +- Server-to-server keys are limited to **public database only** +- No change tracking — must poll with queries +- No custom zones — no atomic batch operations +- Security roles control read/write access + +--- + +###### Data Types **The problem**: CloudKit fields are runtime-dynamic JSON. Swift is statically typed. Mismatch. @@ -84,7 +204,7 @@ Custom type overrides in `openapi-generator-config.yaml` improve ergonomics. Com --- -### 3. Production Error Handling +###### Error Codes CloudKit returns 9 HTTP status codes, each requiring specific handling: @@ -116,7 +236,13 @@ Each error carries nested JSON: `ckErrorCode`, `serverRecord` (on 409), `reason` --- -### 4. API Ergonomics: Three-Layer Architecture +##### Integrating MistKit + +###### Web Application + +###### Background Job + +**Three-Layer Architecture**: **Problem**: OpenAPI-generated code is verbose and low-level. @@ -128,74 +254,19 @@ let request = Operations.SaveRecordsRequest( ) ``` -**Three layers**: - | Layer | Responsibility | |---|---| | **Generated client** (Layer 1) | Auto-generated from OpenAPI spec. Never edit. Low-level REST. | | **MistKit abstraction** (Layer 2) | Auth middleware, retry logic, error handling, response unwrapping, domain type conversion | | **User-facing API** (Layer 3) | Swift-native, intuitive, feels like native CloudKit framework | -**After all three layers**: +After all three layers: ```swift try await database.save(record) // 5 lines, type-safe, production-ready ``` --- -## Production Examples - -| App | Purpose | Auth | Real Challenges | -|---|---|---|---| -| BushelCloud | Syncs macOS/Swift/Xcode version data for Bushel VM | Server-to-server | Concurrent updates from multiple version sources | -| CelestraCloud | Syncs RSS feeds for Celestra RSS reader | Server-to-server | 15-min polling, aggressive rate limiting, conflict resolution | - -**Stats for credibility**: -- **MistKit**: actively maintained open-source library — see the [repo](../../) for current stats -- Built using AI-assisted OpenAPI generation — significantly faster than manual implementation -- **BushelCloud** and **CelestraCloud** are production deployments, each requiring substantial schema migrations - ---- - -## Learning Outcomes - -Audience leaves able to: -1. Implement server-to-server CloudKit auth — key pairs, request signing, environment switching -2. Design type-safe APIs for CloudKit's dynamic fields using OpenAPI discriminated unions -3. Handle all 9 CloudKit HTTP status codes with appropriate retry logic -4. Build the three-layer architecture to make generated code feel Swift-native -5. Decide when CloudKit is the right backend vs. Vapor, Firebase, or Supabase - ---- - -## When to Use CloudKit - -**Use CloudKit when**: -- Building backend for an iOS/macOS app -- Data sync for indie/small team -- Zero server management preferred -- Already in the Apple ecosystem - -**Consider alternatives when**: -- Android support needed → Firebase -- Complex relational queries → PostgreSQL/Supabase -- Real-time updates → Firebase -- Full backend control → Vapor/Hummingbird - -**Reality check**: CloudKit's "free" tier has limits. Rate limiting (429) is real at scale. Factor in discovery time for undocumented auth patterns. - ---- - -## Memorable Phrases - -- *"Apple's worst-documented feature"* — server-to-server authentication -- *"The patterns Apple's documentation doesn't cover"* -- *"AI excels at docs→spec translation; humans needed for architecture"* -- *"Compiler catches type errors, not runtime surprises"* -- *"Zero overlap, complete coverage"* — useful when pairing with a client-side CloudKit talk - ---- - ## Talk Structure Five acts, scalable to any length. @@ -356,6 +427,27 @@ Execute a working query: request signed → response decoded → type-safe field --- +## Memorable Phrases + +- *"Apple's worst-documented feature"* — server-to-server authentication +- *"The patterns Apple's documentation doesn't cover"* +- *"AI excels at docs→spec translation; humans needed for architecture"* +- *"Compiler catches type errors, not runtime surprises"* +- *"Zero overlap, complete coverage"* — useful when pairing with a client-side CloudKit talk + +--- + +## Learning Outcomes + +Audience leaves able to: +1. Implement server-to-server CloudKit auth — key pairs, request signing, environment switching +2. Design type-safe APIs for CloudKit's dynamic fields using OpenAPI discriminated unions +3. Handle all 9 CloudKit HTTP status codes with appropriate retry logic +4. Build the three-layer architecture to make generated code feel Swift-native +5. Decide when CloudKit is the right backend vs. Vapor, Firebase, or Supabase + +--- + ## In This Directory ``` diff --git a/docs/cloudkit-guide/articles/authenticating-cloudkit-backend-services.md b/docs/cloudkit-guide/articles/authenticating-cloudkit-backend-services.md new file mode 100644 index 00000000..7ee4e9ba --- /dev/null +++ b/docs/cloudkit-guide/articles/authenticating-cloudkit-backend-services.md @@ -0,0 +1,367 @@ +--- +title: Beyond the MistKit Tutorials: Authenticating CloudKit from Backend Services +date: 2026-01-01 00:00 +description: A practical walkthrough of the three CloudKit Web Services authentication methods — API tokens, web auth tokens, and server-to-server signing — and how to wire them up from a backend Swift service using MistKit. +featuredImage: /media/tutorials/[VERIFY: path to hero image] +subscriptionCTA: Subscribe for more deep dives on running Swift on the server. +--- + + + +A few years ago I built [HeartWitch](https://github.com/brightdigit/HeartWitch), a service that streams a streamer's live heart rate from their Apple Watch to a browser overlay. The watch was already signed in to iCloud, so making the user retype credentials on a watch face felt absurd — and CloudKit had a perfectly good identity for that user already. The catch: my server didn't run on an Apple platform. It needed to talk to CloudKit over the REST API, and Apple's documentation on how to authenticate that conversation is scattered across half a dozen pages, mostly written assuming a JavaScript browser context. + +This article is the guide I wish I'd had: a practical walkthrough of the three authentication methods CloudKit Web Services supports, when each one applies, and how to wire each one up using [MistKit](https://github.com/brightdigit/MistKit). + +--- + +**In this series:** + +* [Rebuilding MistKit with Claude Code (Part 1)](/tutorials/rebuilding-mistkit-claude-code-part-1/) +* [Rebuilding MistKit with Claude Code (Part 2)](/tutorials/rebuilding-mistkit-claude-code-part-2/) +* _Beyond the MistKit Tutorials: Authenticating CloudKit from Backend Services_ + +--- + +- [Why CloudKit Auth is Different on the Backend](#why-cloudkit-auth-is-different) +- [Method 1: API Token](#method-1-api-token) +- [Method 2: Web Auth Token](#method-2-web-auth-token) + - [Via Browser Redirect (Web Apps)](#getting-web-auth-token-browser) + - [Via iOS App (CKFetchWebAuthTokenOperation)](#getting-web-auth-token-from-ios) +- [Method 3: Server-to-Server (ECDSA)](#method-3-server-to-server) +- [Choosing the Right Method](#choosing-the-right-method) +- [Configuring MistKit](#configuring-mistkit) +- [Production Considerations](#production-considerations) + + +## Why CloudKit Auth is Different on the Backend + +On an Apple platform, CloudKit auth is invisible — the system framework hands the signed-in iCloud identity to your app and you never think about it. On a server, none of that is true. You're talking to `https://api.apple-cloudkit.com` directly, and you have to prove you're allowed to be there with credentials you manage yourself. Apple's [CloudKit Web Services Reference](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/) is the source of truth, but a lot of its examples assume a browser running [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs), which is exactly the context backend services don't have. + +The single most counterintuitive thing here — and the thing every newcomer trips on — is that **the public and private databases use different authentication methods.** A public-database backend service signs requests as itself with an ECDSA key. A private-database backend service acts on behalf of a specific user, holding a token that user obtained by signing into iCloud. There is no method that does both. Pick the database first; the auth method falls out of that choice. + +That gives you really *two and a half* authentication methods: + +| Method | Database | Use Case | +|--------|----------|----------| +| API Token | Public (limited) | Prerequisite for Web Auth Token; limited standalone access to public data | +| Web Auth Token | Private / Shared | Access a specific user's private database (paired with API Token) | +| Server-to-Server | Public | Backend services, daemons, and CLI tools writing to the public database | + +The "half" is the API Token. On its own it does very little — its real job is to be the container identifier for the Web Auth Token flow. + + +## Method 1: API Token + + + +An API Token identifies your CloudKit container but grants limited access on its own. Its primary role in backend auth is as a required companion to the Web Auth Token — without an API Token, you can't initiate the web auth flow at all. + +### Creating an API Token in CloudKit Dashboard + +In the [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/), pick your container and open **Tokens & Keys → API Tokens**. Click the `+` button, give the token a name, and pick a **Sign-in Callback** (more on that below). Optionally tick **User Info** if you want the user's first/last name returned alongside the token. Click **Save**, and the dashboard shows the token string — copy it now, since you'll set it as `CLOUDKIT_API_TOKEN` in your service's environment. + +The Sign-in Callback choice matters because it changes how the Web Auth Token comes back to you: + +- **URL Redirect** — Apple's sign-in page redirects the browser to a URL you supply, with `ckSession` (sometimes called `ckWebAuthToken` in older docs and Stack Overflow answers) appended as a query parameter. This is the mode to pick if your backend handles the callback directly. +- **Post Message** — Apple's sign-in window posts a JavaScript `message` event back to your page containing the token in the event data. This is the mode CloudKit JS uses by default. + +If you're building a backend service with a thin web frontend, **URL Redirect** is the simpler integration: the token shows up as part of a normal HTTP request to your server. + +### Limitations + +An API Token alone cannot access the private database. To read or write a user's private data from a backend service, you must pair it with a Web Auth Token obtained from the user's iCloud session. + + +## Method 2: Web Auth Token + +A Web Auth Token is the only way to access a specific user's private (or shared) database from a backend service. Pure server daemons with no notion of "the user" don't need this method — they want server-to-server. But anything that sits behind a web app or an iOS app and acts on behalf of a signed-in user does. + +There are two ways your backend can get hold of one: the user signs in through a browser redirect (the path Apple's docs spend the most time on), or your iOS app pulls the token from the device's iCloud session via `CKFetchWebAuthTokenOperation` and sends it to your server. + + +### Via Browser Redirect (Web Apps) + +#### The Auth Flow + +The browser-redirect flow looks like this end-to-end: + +1. Your service makes a CloudKit request with only `ckAPIToken` set (no user identity yet). +2. CloudKit replies `401 Unauthorized` with a JSON body whose `serverErrorCode` is `AUTHENTICATION_REQUIRED` and whose `redirectURL` points to Apple's sign-in page. +3. Your service redirects the browser to that URL. +4. The user signs in with their Apple ID. +5. Apple redirects the browser back to the callback URL you registered, appending `ckSession=…` (the web auth token) as a query parameter. +6. Your service stores that token alongside the API token and uses both for every subsequent CloudKit request. + +That `ckSession` parameter is also persisted in a cookie on the same domain when the user opts in to "stay signed in" — useful if you're trying to figure out why a token survives a page refresh in development. + +#### The `AUTHENTICATION_REQUIRED` Response + +The 401 response with `AUTHENTICATION_REQUIRED` is the integration point — it's how CloudKit tells you "this user hasn't authenticated yet; here's where to send them." MistKit surfaces this through its typed error layer so you can pattern-match on it without parsing JSON yourself: + +```swift +do { + _ = try await service.queryRecords(...) +} catch let error as CloudKitError where error.serverErrorCode == .authenticationRequired { + if let redirectURL = error.redirectURL { + response.redirect(to: redirectURL) + } +} +``` + +#### Pairing with the API Token + +Once the user has signed in, every authenticated CloudKit request needs **both** tokens as query parameters: `ckAPIToken=…` (identifies the container) and `ckSession=…` (identifies the user). MistKit's `WebAuthTokenManager` carries both and the `AuthenticationMiddleware` appends them automatically — you never assemble the URL by hand. + + +### Via iOS App (CKFetchWebAuthTokenOperation) + +If your backend acts on behalf of a user who's already signed into your **iOS app**, you don't need the browser redirect at all. The iOS device already has an authenticated CloudKit session, and Apple's framework lets you extract a short-lived web auth token from it that your server can then use. + +The flow looks like this: + +1. **iOS app** runs a [`CKFetchWebAuthTokenOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation) against `CKContainer.default().privateCloudDatabase`, passing the same API token you'd use from the web. +2. **CloudKit framework** exchanges the user's local iCloud session for a `ckWebAuthToken` string. +3. **iOS app** posts that token to your backend over your own API (HTTPS, your own auth — this token is now your responsibility). +4. **Backend** uses MistKit with both the API token and the received web auth token to read or write the user's private database. + +```swift +let op = CKFetchWebAuthTokenOperation(apiToken: apiToken) +op.fetchWebAuthTokenCompletionBlock = { token, error in + guard let token, error == nil else { return } + // POST `token` to your backend over your own API. +} +CKContainer.default().privateCloudDatabase.add(op) +``` + +> **Note:** The MistKit examples in this repo (Bushel, Celestra) use the browser-redirect flow above and the server-to-server flow below — not this iOS handoff path. The flow is documented here for completeness because it's the intended pattern when your backend is paired with your own iOS app, but the MistKit-side integration is identical to the browser-redirect case once your server has the token in hand. + +> **[VERIFY before publishing]** Web-auth-token lifetime, refresh behavior, and whether the token is scoped to a single container are not yet documented here. Check the dashboard or the live API before publishing. + + +## Method 3: Server-to-Server (ECDSA) + + + +Server-to-server authentication uses ECDSA P-256 signing to authenticate as your server rather than as a user. This is the method for daemons, CLI tools, and scheduled jobs that write to the public database. + +### Setting Up in CloudKit Dashboard + +The key pair is **yours, not Apple's**. You generate it locally and hand the dashboard the public half. From the [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/), open your container's **Tokens & Keys → Server-to-Server Keys** and click the `+` button. The dashboard shows you the exact `openssl` command to run; the abbreviated version is: + +```bash +# Generate a P-256 private key +openssl ecparam -name prime256v1 -genkey -noout -out cloudkit-key.pem + +# Derive the public key in the format CloudKit expects, copy to clipboard +openssl ec -in cloudkit-key.pem -pubout | pbcopy +``` + +Paste the public key into the dashboard's text box, name the key, and save. The dashboard returns a **Key ID** — copy that. You now have everything you need: + +- The private key (`cloudkit-key.pem`) — kept on the server, never committed. +- The Key ID — set as `CLOUDKIT_KEY_ID` in your service's environment. + +### What Gets Signed + +For every request, MistKit signs a canonical string with your ECDSA private key. The exact payload is: + +``` +[ISO 8601 date]:[Base64-encoded SHA-256 of body]:[URL subpath] +``` + +For example, the signed string for a query against the public database might look like: + +``` +2026-05-06T14:30:00Z:H+oYzZ…body-hash…=:/database/1/iCloud.com.example.MyApp/development/public/records/query +``` + +The timestamp prevents replay attacks (CloudKit rejects signatures whose date drifts too far from the server clock), and the body hash binds the signature to that specific request payload — anyone tampering with the body invalidates the signature. + +### The Request Header Format + +CloudKit's server-to-server scheme **does not use an `Authorization:` header**. Instead, the signature is split across three custom headers: + +``` +X-Apple-CloudKit-Request-KeyID: [your key ID] +X-Apple-CloudKit-Request-ISO8601Date: [the same date that was signed] +X-Apple-CloudKit-Request-SignatureV1: [base64-encoded ECDSA signature] +``` + +For example, a signed request might carry: + +``` +X-Apple-CloudKit-Request-KeyID: fc9f8fc677ffe615a2e28b6be189f937c093a2393e49556d7fa459497ebb7a4a +X-Apple-CloudKit-Request-ISO8601Date: 2026-05-06T14:30:00Z +X-Apple-CloudKit-Request-SignatureV1: MEUCIQDx3pT8K2v9hN5L1Q3R4sT5uV6wX7yZ8aB9cD0eF1gH2wIgI3jK4lM5nO6pQ7rS8tU9vW0xY1zA2bC3dE4fG5hI6jK= +``` + +If you've used AWS SigV4 or similar schemes, this is similar in spirit but its own dialect. MistKit's `AuthenticationMiddleware` builds these for you on every request — see [`Sources/MistKit/AuthenticationMiddleware.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/AuthenticationMiddleware.swift) and [`Sources/MistKit/Authentication/Internal/RequestSignature.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Authentication/Internal/RequestSignature.swift) for the implementation. + +### Key File Management + +MistKit accepts the private key two ways: + +- `CLOUDKIT_PRIVATE_KEY_PATH` — a filesystem path to the `.pem` file. Best when the key lives on disk (e.g. mounted as a Kubernetes secret). +- `CLOUDKIT_PRIVATE_KEY` — the PEM contents inline as an environment variable. Best in CI environments where secrets are injected as env vars and you'd rather not write them to disk. + +In the [Bushel](https://github.com/brightdigit/BushelCloud) and [Celestra](https://github.com/brightdigit/Celestra) examples, both repos store the PEM contents in **GitHub Actions secrets** and inject them as `CLOUDKIT_PRIVATE_KEY` at job runtime. The job runs on a stock `ubuntu-latest` runner, runs the MistKit-based binary, and exits — the key never touches disk. For non-CI deployments, a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) injecting an env var is the equivalent pattern. + + +## Choosing the Right Method + +A short decision tree: + +- **Are you running in a browser?** Use [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs), not MistKit. MistKit is for code that runs outside Apple's framework — server, CLI, scheduled job, or a non-Swift platform via the Swift toolchain. +- **Do you need to read or write a specific user's private data?** Web Auth Token. The user has to sign in (browser redirect) or hand you a token from your iOS app (`CKFetchWebAuthTokenOperation`). +- **Are you running a daemon, scheduled job, or CLI that writes to the public database on its own behalf?** Server-to-Server. +- **Do you only need to read public data and don't mind being unauthenticated?** API Token alone can do limited reads, but in practice most backend services that touch the public database should use Server-to-Server — writes require it, and you'll likely want them eventually. + +It's also worth knowing what each database actually supports — public, private, and shared databases don't have feature parity: + +| Operation | Public | Private | Shared | +|-----------|:------:|:-------:|:------:| +| Query / lookup records | ✓ | ✓ | ✓ | +| Modify records | ✓ | ✓ | ✓ | +| Record changes (sync) | – | ✓ | ✓ | +| Zones / zone changes | – | ✓ | ✓ | +| Query notifications | ✓ | ✓ | – | +| Asset upload | ✓ | ✓ | ✓ | + + +## Configuring MistKit + + + +### The `TokenManager` Protocol + +`TokenManager` is the seam MistKit uses to plug in any of the three auth methods at runtime. Three concrete implementations ship in the box — `APITokenManager`, `WebAuthTokenManager`, and `ServerToServerAuthManager` — and they all conform to the same protocol. Before each request, `AuthenticationMiddleware` asks the manager for its current `Authenticator` and lets the authenticator apply itself — query parameters for the token-based methods, signed headers for server-to-server. You can also implement your own `TokenManager` if you need to source credentials from a secrets vault or rotate them at runtime. + +`CloudKitService` itself is database-agnostic: the database to target is chosen **per call** on each operation that supports multiple databases (`queryRecords`, `createRecord`, etc.). For `.public`, every call also picks how to attribute itself via `PublicAuthPreference` — `.requires(.serverToServer)`, `.requires(.webAuth)`, or one of the `.prefers(_:)` variants for fallback behavior. Private and shared databases ignore this since CloudKit only accepts web-auth on those scopes. + +### API Token Configuration + +```swift +let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + tokenManager: APITokenManager(apiToken: apiToken), + environment: .development +) + +// Public-database call — API token grants limited reads only. +let results = try await service.queryRecords( + /* ... */ + database: .public(.prefers(.webAuth)) +) +``` + +### Web Auth Token Configuration + +```swift +let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + tokenManager: WebAuthTokenManager( + apiToken: apiToken, + webAuthToken: webAuthToken + ), + environment: .development +) + +// Private-database call — no PublicAuthPreference needed. +let results = try await service.queryRecords( + /* ... */ + database: .private +) +``` + +### Server-to-Server Configuration + +```swift +// PEM contents inline (e.g. from CLOUDKIT_PRIVATE_KEY) +let manager = try ServerToServerAuthManager( + keyID: keyID, + pemString: pemString +) + +// PEM file on disk (e.g. from CLOUDKIT_PRIVATE_KEY_PATH) +let pem = try String(contentsOfFile: privateKeyPath, encoding: .utf8) +let manager = try ServerToServerAuthManager(keyID: keyID, pemString: pem) + +let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + tokenManager: manager, + environment: .development +) + +// Public-database call — service-attributed via S2S signing. +let results = try await service.queryRecords( + /* ... */ + database: .public(.requires(.serverToServer)) +) +``` + +### Reading Credentials from the Environment + +The MistDemo CLI in this repo treats environment variables as the canonical source for credentials, which is exactly what you want on a server: nothing checked in, nothing on disk except where the platform mandates it. The pattern is straightforward — read the env var, fall back to a file path for the key, and bail out with a clear error if anything is missing: + +```swift +let env = ProcessInfo.processInfo.environment + +guard let keyID = env["CLOUDKIT_KEY_ID"] else { + throw ConfigurationError.missingRequired("CLOUDKIT_KEY_ID") +} + +let pem: String +if let inline = env["CLOUDKIT_PRIVATE_KEY"] { + pem = inline.replacingOccurrences(of: "\\n", with: "\n") +} else if let path = env["CLOUDKIT_PRIVATE_KEY_PATH"] { + pem = try String(contentsOfFile: path, encoding: .utf8) +} else { + throw ConfigurationError.missingRequired("CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH") +} + +let manager = try ServerToServerAuthManager(keyID: keyID, pemString: pem) +``` + +The `\\n` → `\n` replacement matters when CI systems (GitHub Actions, GitLab CI, etc.) escape the newlines in the PEM contents on the way through their secret-injection layer. If you store keys in a system that preserves newlines verbatim, you can drop the replacement. + + +## Production Considerations + +### Key Rotation _(Server-to-Server)_ + +Server-to-server keys don't expire on their own, but rotating them periodically is still good hygiene. The dashboard supports multiple active keys per container, so the rotation flow is: + +1. Generate a new key pair locally and add the public key as a new entry in **Tokens & Keys → Server-to-Server Keys**. +2. Roll the new Key ID and PEM into your service's secrets store. +3. Restart your service so it picks up the new credentials. +4. Once you've confirmed the new key is being used (check the CloudKit logs), delete the old key from the dashboard. + +> **[VERIFY before publishing]** Production-rotation experience hasn't been tested end-to-end on the example services yet — confirm the multi-key flow before publishing. + +### Securing Credentials in CI/CD _(Server-to-Server)_ + +Don't commit keys, ever — `.pem` files belong in `.gitignore` from day one. In GitHub Actions (the pattern Bushel and Celestra use), the PEM contents go in **Settings → Secrets and variables → Actions** and the workflow injects them as environment variables on the runner: + +```yaml +env: + CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} + CLOUDKIT_PRIVATE_KEY: ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + CLOUDKIT_CONTAINER_ID: ${{ secrets.CLOUDKIT_CONTAINER_ID }} +``` + +The same pattern works on any modern CI (GitLab CI variables, CircleCI contexts, Jenkins credentials). For long-running services, prefer a real secrets manager — AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault — with the key fetched at startup and injected into the process environment, never written to disk. + +### Local Development vs Production + +CloudKit containers expose two parallel environments — **development** and **production** — and the OpenAPI URL pattern includes which one you're hitting (`/database/{version}/{container}/{environment}/...`). MistKit picks the environment from the `environment:` parameter on `CloudKitService`. Standard practice: + +- During development, deploy schema changes to the development environment, run tests there, and use a separate development container or a development-only API token. +- Promote the schema to production via the dashboard before deploying user-facing code that depends on it. + +> **[VERIFY before publishing]** Whether server-to-server keys are scoped per-environment or shared across both environments isn't documented here yet — check the dashboard before publishing. + +--- + +That's the full picture: pick the database, pick the matching auth method, set the right environment variables, and let MistKit's `AuthenticationMiddleware` handle the wire format. The [`Examples/MistDemo`](https://github.com/brightdigit/MistKit/tree/main/Examples/MistDemo) directory in the repo is a working reference for all three methods — it's the same code that runs against the real CloudKit container in MistKit's integration tests, so you can copy from it with confidence. The [Bushel](https://github.com/brightdigit/BushelCloud) and [Celestra](https://github.com/brightdigit/Celestra) repos show the GitHub Actions deployment pattern end to end, including the cron-scheduled scrape jobs that ultimately update a CloudKit public database from a stock Ubuntu runner. + +📚 **[View Documentation](https://swiftpackageindex.com/brightdigit/MistKit/documentation)** | 🐙 **[GitHub Repository](https://github.com/brightdigit/MistKit)** diff --git a/docs/cloudkit-guide/articles/deploying-mistkit-server-side.md b/docs/cloudkit-guide/articles/deploying-mistkit-server-side.md new file mode 100644 index 00000000..dafb65b2 --- /dev/null +++ b/docs/cloudkit-guide/articles/deploying-mistkit-server-side.md @@ -0,0 +1,445 @@ +--- +title: Deploying MistKit - From Local CLI to a Scheduled CloudKit Job in CI +date: 2026-06-01 00:00 +description: A practical walkthrough of running a MistKit-based service or scheduled job in production - how to build a static Linux binary, manage CloudKit credentials, and structure GitHub Actions workflows for tiered scheduled sync. Built around two real production deployments, BushelCloud and CelestraCloud. +featuredImage: /media/tutorials/[VERIFY: path to hero image] +subscriptionCTA: Subscribe for more deep dives on running Swift on the server. +--- + + + + + +The hard part of using MistKit on a backend isn't writing the code - it's deciding where the code runs, how the credentials get there, and what happens when nobody's watching. Once you've got CloudKit working from a local CLI, the next question is: how do I run this on a schedule, on Linux, without a Mac in the loop? + +This article is the deployment guide that picks up where the [authentication walkthrough](/tutorials/authenticating-cloudkit-backend-services/) leaves off. Instead of focusing on which auth method to pick, it focuses on the operational side: how to build, package, and run a MistKit-based service so it works reliably on a server, in a container, or as a scheduled CI job. Two production deployments - [BushelCloud](https://github.com/brightdigit/BushelCloud) and [CelestraCloud](https://github.com/brightdigit/CelestraCloud) - are used throughout as worked examples, because both ship today as scheduled CI jobs that write to a CloudKit public database from stock Ubuntu runners. + +--- + +**In this series:** + +* [Rebuilding MistKit with Claude Code (Part 1)](/tutorials/rebuilding-mistkit-claude-code-part-1/) +* [Rebuilding MistKit with Claude Code (Part 2)](/tutorials/rebuilding-mistkit-claude-code-part-2/) +* [Authenticating CloudKit from Backend Services](/tutorials/authenticating-cloudkit-backend-services/) +* _Deploying MistKit: From Local CLI to a Scheduled CloudKit Job in CI_ + +--- + +- [What "Deploying" Actually Means Here](#what-deploying-means) +- [Workload Shapes: Server vs. Scheduled Job](#workload-shapes) +- [Picking an Auth Method for Your Deployment](#picking-auth-method) + - [Server-to-Server (Autonomous Services, Scheduled Jobs)](#auth-s2s) + - [API Token (Public-Database Readers)](#auth-api-token) + - [Web Auth Token (Acting on Behalf of a User)](#auth-web-token) +- [Building a Deployable Binary](#building-a-deployable-binary) + - [Static Linux Builds](#static-linux-builds) + - [Binary Caching in CI](#binary-caching-in-ci) +- [Providing Credentials at Runtime](#providing-credentials) + - [The Environment Variable Contract](#env-var-contract) + - [Inline Values vs. File Paths](#inline-vs-path) + - [Validating the Key Before You Hit CloudKit](#validating-the-key) + - [Wiring It Up in Different Runtimes](#runtime-wiring) +- [Scheduling Strategies](#scheduling-strategies) + - [Single-Cron: BushelCloud's Pattern](#single-cron-bushelcloud) + - [Tiered Scheduling: CelestraCloud's Pattern](#tiered-celestracloud) + - [Avoiding the Thundering Herd](#thundering-herd) +- [Concurrency, Idempotency, and Retries](#concurrency-and-retries) +- [Observability: Reporting from a Cron Job](#observability) +- [Dev vs. Prod CloudKit Environments](#dev-vs-prod) + + +## What "Deploying" Actually Means Here + +"Deploying" a MistKit-based service can mean one of three things, depending on the workload: + +1. **A long-running web service** that handles user requests and talks to CloudKit on their behalf (typically with a Web Auth Token, or system-attributed with Server-to-Server). +2. **A scheduled job** - a CLI or daemon that wakes up on a cron, pulls data from somewhere, and writes it to CloudKit (typically with Server-to-Server auth). +3. **A one-shot CLI** that a human runs occasionally - data import, schema bootstrapping, audits. + +The first is closest to a "normal" web app deployment - your existing Vapor/Hummingbird playbook applies and MistKit is just another HTTP client inside it. The second is where backend CloudKit actually shines and where the operational patterns are non-obvious: there's no user session to lean on, no UI to report progress, and no Apple-supplied infrastructure to fall back on. The third is mostly the local-dev story plus credential hygiene. + +Both example repos in this series - BushelCloud and CelestraCloud - are case (2): scheduled jobs running in GitHub Actions on Ubuntu. That's the shape this article spends the most time on, since it's the least documented. The build, credential, and observability sections also apply directly to case (1) - the only thing that differs is the scheduler. + + +## Workload Shapes: Server vs. Scheduled Job + +| Concern | Long-running service | Scheduled job | +|---------|---------------------|---------------| +| **Auth** | Web Auth Token (per user), API Token (public reads), or S2S (system-attributed) | Server-to-Server (or API Token for read-only public sync) | +| **Runtime** | Vapor/Hummingbird host, kept warm | Container or `runs-on:` runner, exits on completion | +| **Credentials** | Long-lived secrets in the process environment | Injected per-run from CI secrets | +| **Idempotency** | Per-request | Per-run - "what if this fires twice?" matters more | +| **Observability** | Existing APM / logs | Job summary, artifacts, optional notification | +| **Failure mode** | Returns 5xx to caller | Silent unless you wire up alerts | + +The scheduled-job column is where the worked examples live, but most of the operational patterns - building a portable binary, injecting credentials from the environment, validating the key before first use - port directly to the long-running-service column. + + +## Picking an Auth Method for Your Deployment + +The [authentication walkthrough](/tutorials/authenticating-cloudkit-backend-services/) covers the three methods in detail. From a deployment perspective, the key question is: **what credentials does my running process need to have available, and where do they come from?** That question has three different answers. + + +### Server-to-Server (Autonomous Services, Scheduled Jobs) + +This is what most of this article is about. Your deployment needs to ship with: + +- `CLOUDKIT_KEY_ID` - the Key ID string from the CloudKit Dashboard +- `CLOUDKIT_PRIVATE_KEY` (inline PEM) **or** `CLOUDKIT_PRIVATE_KEY_PATH` (filesystem path) + +The PEM file is the sensitive piece - it's the private half of an ECDSA P-256 key pair, and it's how your service proves it's allowed to write to the public database. Limited to the **public database only**. + +Use S2S when: scheduled jobs, daemons, CLIs that write data on their own behalf, or a long-running service that operates as itself (not on behalf of a user) and only needs the public database. + + +### API Token (Public-Database Readers) + +The simplest possible credentialing for a backend service: + +- `CLOUDKIT_API_TOKEN` - a single string from the CloudKit Dashboard + +No signing, no key file, no clock-synchronized timestamps. Just an env var. The trade-off is that an API Token alone grants only limited public-database access - you can read public records that have a security role of `_world`, but you can't write, and you can't touch the private or shared databases. + +Use API Token when: your backend service only **reads** data from the public database and you don't care about per-user attribution. A read replica that mirrors a CloudKit-hosted dataset into a search index, a status page that surfaces public-database counts, a thin REST proxy that exposes a curated subset of public records - all good fits. + +The deployment story collapses to "set one env var" - everything in [Providing Credentials at Runtime](#providing-credentials) below still applies, but the PEM-validation step and the file-on-disk pattern don't. + + +### Web Auth Token (Acting on Behalf of a User) + +Web Auth Token requires **both**: + +- `CLOUDKIT_API_TOKEN` - identifies the container +- `CLOUDKIT_WEB_AUTH_TOKEN` - identifies the specific user + +The second token is per-user and arrives at your service through one of the flows documented in the auth article (browser redirect or `CKFetchWebAuthTokenOperation` handoff from an iOS app). It's not something you'd typically set as a static environment variable - it's something your service receives at request time and passes through to MistKit on a per-request basis. + +There's no obvious reason to run a *scheduled job* with a Web Auth Token - schedule cycles outlive any reasonable user session, and the token would need refreshing on a cadence that defeats the point. It shows up in the deployment story only when a long-running web service holds tokens in a session store and uses MistKit to act on behalf of whichever user is currently making a request. + +[VERIFY: web-auth-token lifetime and refresh behavior aren't clearly documented; confirm before publishing whether long-lived scheduled use is even practically possible.] + + +## Building a Deployable Binary + + +### Static Linux Builds + +MistKit targets cross-platform Swift, so the deployment artifact for a Linux service or scheduled job is a single statically-linked binary that doesn't need a Swift runtime on the host. Both BushelCloud and CelestraCloud build with `--static-swift-stdlib` against the official Swift Docker image: + +```bash +swift build -c release --static-swift-stdlib +``` + +In CI, the build happens inside a `swift:6.2-noble` (Ubuntu Noble) container so the resulting binary is portable across any modern Ubuntu runner. CelestraCloud invokes this with `container: swift:6.2-noble` at the job level; BushelCloud uses `docker run --rm` inside a `runs-on: ubuntu-latest` step for the fallback build path. Either approach works - the container-at-job-level form is slightly cleaner when every step in a job needs the Swift toolchain. + +The same `--static-swift-stdlib` binary drops straight into a distroless or `ubuntu:noble` container image for non-CI deployment targets (Kubernetes, Fly.io, a plain `systemd` unit on a VPS). No Swift runtime needed on the host. + +[VERIFY: confirm Swift 6.2 is still the right minimum on a fresh `ubuntu-latest` image at publish time - this may have advanced.] + + +### Binary Caching in CI + +A `swift build -c release --static-swift-stdlib` from scratch in the Swift Docker image takes ~2 minutes on a stock `ubuntu-latest` runner. For a job that runs three times a day, that's six wasted minutes daily - and worse, it's six minutes during which a transient toolchain or network hiccup could fail a scheduled production run. + +The pattern both repos use is to **build the binary once and cache it**: + +```yaml +- name: Cache compiled binary + id: cache-binary + uses: actions/cache@v4 + with: + path: .build/release/celestra-cloud + key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ github.event.inputs.force_rebuild || 'false' }} +``` + +The cache key is keyed on the hash of the Swift sources and `Package.swift`, so any code change invalidates it. CelestraCloud also wires an `actions/upload-artifact@v4` step after the build and `actions/download-artifact@v4` in each subsequent job, so a single build feeds multiple downstream sync jobs in the same workflow run (one per feed tier). + +BushelCloud takes a similar shape but pulls the binary from a separate `bushel-cloud-build.yml` workflow's artifact, falling back to an inline build if the artifact has expired (GitHub Actions artifact retention defaults to 90 days). The fallback path is worth copying - it prevents a stale-artifact failure on day 91 from breaking your scheduled run. + +For a long-running server deployment, the equivalent of "binary caching" is just shipping a built image: one CI workflow builds and publishes the container, the runtime pulls and runs it. Same end state, different scheduler. + + +## Providing Credentials at Runtime + +The credential-injection patterns below apply regardless of whether MistKit is running as a scheduled GitHub Actions job, a long-running container on Kubernetes, a systemd-managed daemon on a VPS, or a developer's local CLI. The only thing that changes per environment is *how* the values get into the process's environment - MistKit itself just reads them. + + +### The Environment Variable Contract + +MistKit (and the example CLIs) read all credentials from `ProcessInfo.processInfo.environment`. The full set of variables, by auth method: + +| Variable | Method | Purpose | +|----------|--------|---------| +| `CLOUDKIT_CONTAINER_ID` | All | Container identifier, e.g. `iCloud.com.example.MyApp` | +| `CLOUDKIT_ENVIRONMENT` | All | `development` or `production` | +| `CLOUDKIT_API_TOKEN` | API Token / Web Auth | Public-DB token from Dashboard | +| `CLOUDKIT_WEB_AUTH_TOKEN` | Web Auth | Per-user token from sign-in flow | +| `CLOUDKIT_KEY_ID` | S2S | Server-to-Server key ID from Dashboard | +| `CLOUDKIT_PRIVATE_KEY` | S2S | Inline PEM contents | +| `CLOUDKIT_PRIVATE_KEY_PATH` | S2S | Filesystem path to PEM file | + +A typical bootstrap in your service entrypoint reads these once at startup and constructs the `CloudKitService`: + +```swift +let env = ProcessInfo.processInfo.environment + +guard let containerID = env["CLOUDKIT_CONTAINER_ID"] else { + throw ConfigurationError.missingRequired("CLOUDKIT_CONTAINER_ID") +} + +let environment: CloudKitEnvironment = + env["CLOUDKIT_ENVIRONMENT"] == "production" ? .production : .development + +// S2S path - read PEM inline or from disk +guard let keyID = env["CLOUDKIT_KEY_ID"] else { + throw ConfigurationError.missingRequired("CLOUDKIT_KEY_ID") +} + +let pem: String +if let inline = env["CLOUDKIT_PRIVATE_KEY"] { + pem = inline.replacingOccurrences(of: "\\n", with: "\n") +} else if let path = env["CLOUDKIT_PRIVATE_KEY_PATH"] { + pem = try String(contentsOfFile: path, encoding: .utf8) +} else { + throw ConfigurationError.missingRequired("CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH") +} + +let manager = try ServerToServerAuthManager(keyID: keyID, pemString: pem) +let service = CloudKitService( + containerIdentifier: containerID, + tokenManager: manager, + environment: environment +) +``` + +The `\\n` → `\n` replacement matters when the secrets-injection layer escapes newlines in the PEM contents (GitHub Actions, GitLab CI, and a handful of others do this). If your environment preserves newlines verbatim, you can drop the replacement. + + +### Inline Values vs. File Paths + +For the Server-to-Server PEM specifically, MistKit accepts the key two ways: inline as `CLOUDKIT_PRIVATE_KEY`, or via a filesystem path in `CLOUDKIT_PRIVATE_KEY_PATH`. The choice depends on where the credential comes from in the host environment: + +- **Inline** is the simplest path when the credential comes from a CI secret store or a `.env` file - you pass the PEM string through as-is and the key never touches disk. +- **File path** is what you want when the credential is mounted as a file by the platform - Kubernetes secrets, systemd's `LoadCredential=`, Docker secrets, a secrets-manager CSI driver. Pointing at the mount path means you get the platform's encryption-at-rest and rotation handling for free. + +BushelCloud's composite action uses the inline form: + +```yaml +env: + CLOUDKIT_KEY_ID: ${{ inputs.cloudkit-key-id }} + CLOUDKIT_PRIVATE_KEY: ${{ inputs.cloudkit-private-key }} +``` + +CelestraCloud writes the PEM to a temp file first, then points MistKit at the path: + +```yaml +env: + CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem + +steps: + - name: Create CloudKit private key file + run: | + cat <<'EOF' > $CLOUDKIT_PRIVATE_KEY_PATH + ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + EOF + chmod 600 $CLOUDKIT_PRIVATE_KEY_PATH + + # ... sync step uses the binary with CLOUDKIT_PRIVATE_KEY_PATH set ... + + - name: Cleanup private key + if: always() + run: rm -f $CLOUDKIT_PRIVATE_KEY_PATH +``` + +Two things to call out: the `chmod 600` (so only the runner user can read it) and the `if: always()` cleanup step (so the key is removed even when the sync step fails). Neither matters much on an ephemeral runner that's thrown away after the run, but both are non-optional on long-lived hosts. + + +### Validating the Key Before You Hit CloudKit + +A truncated PEM doesn't fail at parse time - it fails when you try to sign a request, and the failure mode is a generic `401 AUTHENTICATION_FAILED` from CloudKit with no detail on _why_. BushelCloud's composite action validates the PEM format before the sync step runs, which dramatically shortens the debugging loop on credential rotation: + +```bash +if ! grep -q "BEGIN.*PRIVATE KEY" <<< "$CLOUDKIT_PRIVATE_KEY"; then + echo "Error: PEM header not found" + echo "Common issues: missing BEGIN/END markers, extra whitespace, copy/paste truncation" + exit 1 +fi + +if ! grep -q "END.*PRIVATE KEY" <<< "$CLOUDKIT_PRIVATE_KEY"; then + echo "Error: PEM footer not found" + exit 1 +fi + +# Validate base64 content between headers +PEM_CONTENT=$(sed -n '/BEGIN/,/END/p' <<< "$CLOUDKIT_PRIVATE_KEY" | grep -v "BEGIN\|END") +if ! base64 -d >/dev/null 2>&1 <<< "$PEM_CONTENT"; then + echo "Error: PEM content is not valid base64" + exit 1 +fi +``` + +The `<<< "$VAR"` (here-string) form is deliberate: it keeps the secret out of the process argument list, which on Linux is visible to other users via `/proc/*/cmdline`. Don't pipe secrets through `echo "$PEM" | grep` if you can avoid it. + +For a long-running service, the same check belongs in the startup health check - fail loudly at boot rather than on the first request. + +[VERIFY: GitHub's secret-redaction handles the `echo`-into-pipe case fine for log output, but the process-list visibility is still real on shared runners. Confirm before publishing.] + + +### Wiring It Up in Different Runtimes + +Same env-var contract, different injection mechanism per runtime: + +- **Local development** - A `.env` file in the project root, sourced with `source .env` or loaded by a library like Apple's [swift-configuration](https://github.com/apple/swift-configuration) (CelestraCloud does this - see its `Configuration/` directory). Add `.env` to `.gitignore`. +- **GitHub Actions / GitLab CI** - Secrets stored in the project's secret store, exposed via `env:` blocks or `${{ secrets.NAME }}` interpolation, as shown above. +- **Docker / Compose** - `environment:` block in `docker-compose.yml`, `env_file:`, or `--env-file` at `docker run` time. +- **Kubernetes** - `Secret` resources, projected into the pod either as env vars (`envFrom: secretRef:`) or as files (`volumeMounts: + secret:`). The file form pairs naturally with `CLOUDKIT_PRIVATE_KEY_PATH`. +- **systemd on a VPS** - `EnvironmentFile=` in the unit file for plain env vars; `LoadCredential=` (on systems with credential-encryption) for keys that should stay encrypted at rest. +- **Managed platforms (Fly.io, Railway, Render, Lambda)** - Each has its own "environment variables" or "secrets" tab in the dashboard. The injected values end up in `ProcessInfo.processInfo.environment` exactly the same way. + +The MistKit side doesn't care which of these you use - it just reads the environment. + + +## Scheduling Strategies + +GitHub Actions' `on: schedule:` is the easy part - cron syntax, one line, done. The interesting design decisions are around _what_ to schedule and _how often_. (For long-running services this section doesn't apply - skip to [Concurrency, Idempotency, and Retries](#concurrency-and-retries).) + + +### Single-Cron: BushelCloud's Pattern + +BushelCloud syncs macOS / Xcode / Swift version data three times a day. There's only one logical job ("scrape upstream sources, write to CloudKit"), and the scheduling reflects that: + +```yaml +on: + schedule: + - cron: '17 2 * * *' # 02:17 UTC + - cron: '43 10 * * *' # 10:43 UTC + - cron: '29 18 * * *' # 18:29 UTC + + workflow_dispatch: # Manual trigger for testing +``` + +The three offsets are chosen to give roughly 8-hour spacing, aligned with the VirtualBuddy TSS API's 12-hour cache lifetime (one of BushelCloud's upstream data sources). Manual `workflow_dispatch` is left on for ad-hoc reruns and for the "I just merged a fix and want to see it run now" case. + +Production sync runs are kept on `workflow_dispatch` only - the live production CloudKit container is only updated when a human explicitly clicks the button, after the development environment has had a clean run. This is one of those policy decisions that's worth committing to early. + + +### Tiered Scheduling: CelestraCloud's Pattern + +CelestraCloud is more interesting because not all RSS feeds are equal. Popular feeds want frequent refresh; feeds that haven't published in months can be checked weekly. The workflow encodes this with multiple cron lines, a `determine-tier` job that inspects the current hour, and a set of downstream jobs gated on tier outputs: + +```yaml +on: + schedule: + - cron: '0 2 * * *' # Daily: standard feeds + - cron: '0 3 * * 0' # Weekly Sunday: stale feeds +``` + +The `determine-tier` job reads `date -u +%H` and emits a `tier` output (`standard`, `stale`, `high`, or `pr-test`); each downstream job has an `if: needs.determine-tier.outputs.runs_standard == 'true'` guard. The result is a single workflow file that the cron scheduler can fire on multiple schedules without duplicating per-tier YAML. + +Within a tier, the actual MistKit call is parameterized by the tier's filters: + +```yaml +# High-priority tier - matrix of two passes with different popularity thresholds +strategy: + matrix: + include: + - name: "Pass 1: Very popular feeds" + args: "--update-min-popularity 100 --update-max-failures 2 --update-delay 2.0 --update-limit 100" + - name: "Pass 2: Popular feeds" + args: "--update-min-popularity 10 --update-max-failures 5 --update-delay 2.5 --update-limit 100" +``` + +Those `--update-*` flags map directly to MistKit's `QueryFilter` API - the CLI is just a thin wrapper that converts CLI arguments into filter parameters on the CloudKit query. The same pattern works for any cron job that needs to process "the top N by some metric" without scanning the whole table. + + +### Avoiding the Thundering Herd + +Both repos schedule at non-:00 minute offsets - `17`, `29`, `43` for BushelCloud. This isn't paranoia: GitHub Actions has a real bias toward delaying jobs scheduled at exactly `:00` past common UTC boundaries (top of the hour, midnight UTC), because that's when half the world's cron jobs fire. Picking a prime-ish minute offset typically gets you closer to the actual intended fire time. + +[VERIFY: GitHub's official docs note that scheduled workflows can be delayed during periods of high load, particularly at the start of an hour. Quote the current doc text before publishing.] + + +## Concurrency, Idempotency, and Retries + +Both repos use GitHub Actions' `concurrency:` group with `cancel-in-progress: true` to guarantee that a new sync run cancels any older one still in flight: + +```yaml +concurrency: + group: cloudkit-sync-dev + cancel-in-progress: true +``` + +This is safe **only because the underlying job is idempotent**. BushelCloud uses deterministic record names based on build numbers and `.forceReplace` operations, so re-running a sync updates existing records instead of creating duplicates. CelestraCloud queries by GUID before upload and skips articles that already exist. Neither cares whether a previous run finished cleanly. + +If your job isn't idempotent - say, it appends to a log or increments a counter - you want `cancel-in-progress: false` (the default) and an explicit lock at the application level (e.g. a CloudKit record that acts as a leader-election token). + +MistKit itself doesn't do automatic retry on transient CloudKit errors today. For 429 (rate limit) and 503 (transient unavailability), the typical pattern is a small wrapper at the operation site that catches `CloudKitError`, checks the `serverErrorCode`, and retries with exponential backoff: + +```swift +func withRetry(_ op: () async throws -> T) async throws -> T { + var delay: UInt64 = 1_000_000_000 // 1s in nanoseconds + for attempt in 1...5 { + do { return try await op() } + catch let error as CloudKitError + where error.serverErrorCode == .tooManyRequests + || error.serverErrorCode == .serviceUnavailable { + if attempt == 5 { throw error } + try await Task.sleep(nanoseconds: delay) + delay *= 2 + } + } + fatalError("unreachable") +} +``` + +[VERIFY: confirm `CloudKitError.serverErrorCode` enum cases are exactly `.tooManyRequests` and `.serviceUnavailable` at publish time - these names may have evolved.] + + +## Observability: Reporting from a Cron Job + +The hardest part of a quiet scheduled job is knowing whether it actually ran and what it did. Both repos solve this with two-step reporting: the CLI emits a structured JSON report, and a downstream CI step parses that report into a `$GITHUB_STEP_SUMMARY` (which becomes the rich summary view on the workflow run page). + +CelestraCloud's pattern uses a `--update-json-output-path` flag on the CLI: + +```bash +./bin/celestra-cloud update \ + --update-limit 5 \ + --update-max-failures 0 \ + --update-json-output-path ./feed-update-pr-test.json +``` + +A separate `summary` job then `jq`'s the resulting JSON files and writes a markdown summary: + +```bash +total_feeds=$(jq -r '.summary.totalFeeds // 0' "$json_file") +success_count=$(jq -r '.summary.successCount // 0' "$json_file") +echo "- **Total Feeds Processed:** $total_feeds" >> $GITHUB_STEP_SUMMARY +echo "- **Successful:** $success_count" >> $GITHUB_STEP_SUMMARY +``` + +BushelCloud does the same with a `BUSHEL_SYNC_JSON_OUTPUT_FILE` environment variable, plus a per-record-type breakdown of created / updated / failed counts. The summary lives at `$GITHUB_STEP_SUMMARY` and surfaces as the workflow run's "Summary" view in the GitHub UI - no need for an external dashboard or alerting service in the early days of a deployment. + +For production alerting, both repos retain the JSON report as a workflow artifact (`actions/upload-artifact@v4` with `retention-days: 30` or `90`), so a separate process - a daily Slack digest, a dashboard scrape, a manual audit - can pull historical results without re-running the job. + +For a long-running service, the equivalent is the request-level logging you already have - structured logs flowing into your existing aggregator, plus health-check endpoints that exercise a representative MistKit call so you find out about auth or schema drift before users do. + + +## Dev vs. Prod CloudKit Environments + +CloudKit containers expose two parallel environments: `development` and `production`. The MistKit `environment:` parameter on `CloudKitService` (or the `CLOUDKIT_ENVIRONMENT` env var that the example CLIs read) selects which one a given run targets. + +The deployment pattern that works in practice: + +1. **Two separate workflows or service deployments**, one per environment. BushelCloud has `cloudkit-sync-dev.yml` (scheduled, 3x daily) and `cloudkit-sync-prod.yml` (`workflow_dispatch:` only). +2. **Two sets of secrets**, suffixed `_DEV` and `_PROD` in the repo's secret store. The workflow or container references the appropriate set explicitly - no shared "default" secret that one accidentally cross-contaminates. +3. **Schema changes go through dev first**, deployed via `cktool` and verified by the next scheduled dev sync. Once the dev sync is clean for a day, promote the schema to production and trigger the prod deployment. + +This is the same dev/prod hygiene as any backend, just with CloudKit's specific quirk that the schema lives on Apple's infrastructure and has to be promoted explicitly via `cktool` (CloudKit Dashboard or `xcrun cktool deploy-schema-changes`). + +[VERIFY: CloudKit schema promotion from dev to prod via `cktool` - check the exact subcommand at publish time, as it has shifted between Xcode versions.] + +--- + +That's the operational picture: pick the right auth method for your workload, build a static binary, inject the credentials from your platform's secrets mechanism, and (for scheduled jobs) make the job idempotent and surface a structured report so you can tell what it did. The [`Examples/BushelCloud`](https://github.com/brightdigit/MistKit/tree/main/Examples/BushelCloud) and [`Examples/CelestraCloud`](https://github.com/brightdigit/MistKit/tree/main/Examples/CelestraCloud) directories in the MistKit repo are working references for everything in this article - both ship with the GitHub Actions workflows referenced above and have been running on schedule for months. Clone either one as a starting point and replace the data layer with your own. + +📚 **[View Documentation](https://swiftpackageindex.com/brightdigit/MistKit/documentation)** | 🐙 **[GitHub Repository](https://github.com/brightdigit/MistKit)** diff --git a/docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-1.md b/docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-1.md new file mode 100644 index 00000000..03cb6b26 --- /dev/null +++ b/docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-1.md @@ -0,0 +1,535 @@ +--- +title: Rebuilding MistKit with Claude Code - From CloudKit Docs to Type-Safe Swift (Part 1) +date: 2025-12-01 00:00 +description: Follow the journey of rebuilding MistKit using Claude Code and swift-openapi-generator. Learn how OpenAPI specifications transformed Apple's CloudKit documentation into a type-safe Swift client, and discover the challenges of mapping CloudKit's quirky REST API to modern Swift patterns. +featuredImage: /media/tutorials/rebuilding-mistkit-claude-code/mistkit-rebuild-part1-hero.webp +subscriptionCTA: Want to learn more about AI-assisted Swift development? Sign up for our newsletter to get notified when Part 2 drops. +--- + +In my previous article about [Building SyntaxKit with AI](https://brightdigit.com/tutorials/syntaxkit-swift-code-generation/), I explored how with the help of [Claude Code](https://claude.ai/claude-code) I could transform SwiftSyntax's 80+ lines of verbose API calls into 10 lines of elegant, declarative Swift. + +I saw how Claude Code could easily replace and understand patterns. That's when I decided to explore the idea of updating [MistKit](https://github.com/brightdigit/MistKit), my library for server-side CloudKit application and see how Claude Code can help. + +--- + +**In this series:** + +* [Building SyntaxKit with AI](/tutorials/syntaxkit-swift-code-generation/) +* _Rebuilding MistKit with Claude Code (Part 1)_ +* [Rebuilding MistKit with Claude Code (Part 2)](/tutorials/rebuilding-mistkit-claude-code-part-2/) + +--- + +📚 **[View Documentation](https://swiftpackageindex.com/brightdigit/MistKit/documentation)** | 🐙 **[GitHub Repository](https://github.com/brightdigit/MistKit)** + +- [The Decision to Rebuild](#the-decision-to-rebuild) + - [The Game Changer: swift-openapi-generator](#the-game-changer-swift-openapi-generator) + - [Learning from SyntaxKit's Pattern](#learning-from-syntaxkits-pattern) +- [Building with Claude Code](#building-with-claude-code) + - [Why OpenAPI + swift-openapi-generator?](#why-openapi--swift-openapi-generator) + - [Challenge #1: Type System Polymorphism](#challenge-1-type-system-polymorphism) + - [Challenge #2: Authentication Complexity](#challenge-2-authentication-complexity) + - [Challenge #3: Error Handling](#challenge-3-error-handling) + - [Challenge #4: API Ergonomics](#challenge-4-api-ergonomics) + - [The Iterative Workflow with Claude](#the-iterative-workflow-with-claude) +- [What's Next](#whats-next) + + +## The Decision to Rebuild + +I had a couple of use cases where MistKit running in the cloud would allow me to store data in a public database. However I hadn't touched the library in a while. + +By now, [Swift had transformed](https://brightdigit.com/tutorials/swift-6-async-await-actors-fixes/) while MistKit stood still: +- **Swift 6** with strict concurrency checking +- **async/await** as standard (not experimental) +- **Server-side Swift maturity** (Vapor 4, swift-nio, AWS Lambda) +- **Modern patterns** expected (Result types, AsyncSequence, property wrappers) + +MistKit, frozen in 2021, couldn't take advantage of any of this. + +> youtube https://youtu.be/_-k97s1ZPzE + + +### The Game Changer: [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) + +At [WWDC 2023](https://developer.apple.com/videos/play/wwdc2023/10171/), Apple announced [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator)—a tool that reads OpenAPI specifications and automatically generates type-safe Swift client code. This single tool made the MistKit rebuild feasible. What was missing was an OpenAPI spec. If I had that I could easily create a library which made the necessary calls to CloudKit as needed, as well as compatibility with [server-side (AsyncHTTPClient)](https://github.com/swift-server/swift-openapi-async-http-client) or [client-side (URLSession)](https://github.com/apple/swift-openapi-urlsession) APIs . + +That's where [Claude Code](https://claude.ai/claude-code) came in. + + +### Learning from SyntaxKit's Pattern + +With my work on SyntaxKit, I could see that if I fed sufficient documentation on an API to an LLM, it can understand how to develop against it. There may be issues along the way. However, any failures come with the ability to learn and adapt either with internal documentation or writing sufficient tests. + +Just as I was able to simplify SwiftSyntax into a simpler API with [SyntaxKit](https://github.com/brightdigit/SyntaxKit), I can have an LLM create an OpenAPI spec for CloudKit. + +--- + +The pattern was clear: **give Claude the right context, and it could translate Apple's documentation into a usable OpenAPI spec**. SyntaxKit taught me that code generation works best when you have a clear source of truth—for SyntaxKit it was SwiftSyntax ASTs, for MistKit it would be CloudKit's REST API documentation. The abstraction layer would come later. + +The rebuild was ready to begin. + +![CloudKit Web Services Documentation Site](/media/tutorials/rebuilding-mistkit-claude-code/cloudkit-documentation.webp) + + +## Building with [Claude Code](https://claude.ai/claude-code) + +I needed a way for Claude Code to understand how the CloudKit REST API worked. There was one main document I used—the [CloudKit Web Services Documentation Site](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/). The [CloudKit Web Services Documentation](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/) Site, **which hasn't been updated since June of 2016**, contains the most thorough documentation on how the REST API works and hopefully can provide enough for Claude to start crafting the OpenAPI spec. + +By running the site (as well as the swift-openapi-generator documentation) through llm.codes, saving the exported markdown documentation in the `.claude/docs` directory and letting Claude Code know about it (i.e. add a reference to it in Claude.md), I could now start having Claude Code translate the documentation into a usable API. + +### Setting Up Claude Code for MistKit + +Before diving in, here's what you need to understand about working with Claude Code: + +**Documentation Export with llm.codes** +I used [llm.codes](https://llm.codes) (mentioned in my [SyntaxKit article](https://brightdigit.com/tutorials/syntaxkit-swift-code-generation/)) to convert Apple's web documentation into markdown format that Claude can easily understand. This tool crawls documentation sites and exports them as clean markdown files. It also works with DocC documentation from Swift packages, making it easy to give Claude context about any Swift library's API. + +**Claude Code's Context System** +Claude Code uses a simple but powerful context system: +- `.claude/docs/` - Store reference documentation (like CloudKit API docs, swift-openapi-generator guides) +- `.claude/CLAUDE.md` or `CLAUDE.md` - Reference these docs so Claude knows to use them as context + +This gives Claude the context it needs to understand CloudKit's API without you having to paste documentation repeatedly in every conversation. + +``` +.claude/docs +├── cktool-full.md # Complete CloudKit CLI tool documentation +├── cktool.md # Condensed CloudKit CLI reference +├── cktooljs-full.md # Full CloudKitJS documentation +├── cktooljs.md # CloudKitJS quick reference +├── cloudkit-public-database-architecture.md +├── cloudkit-schema-plan.md +├── cloudkit-schema-reference.md +├── cloudkitjs.md # JavaScript SDK documentation +├── data-sources-api-research.md +├── firmware-wiki.md +├── https_-swiftpackageindex.com-apple-swift-log-main-documentation-logging.md +├── https_-swiftpackageindex.com-apple-swift-openapi-generator-1.10.3-documentation-swift-openapi-generator.md +├── https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md +├── mobileasset-wiki.md +├── protocol-extraction-continuation.md +├── QUICK_REFERENCE.md +├── README.md +├── schema-design-workflow.md +├── sosumi-cloudkit-schema-source.md +├── SUMMARY.md +├── testing-enablinganddisabling.md +└── webservices.md # Primary CloudKit Web Services REST API documentation +``` + +Note: Files with "-full" suffix contain complete documentation exported from llm.codes, while shorter versions are condensed for quicker reference. The swift-openapi-generator docs were essential for understanding type overrides and middleware configuration. + + +### Why OpenAPI + [swift-openapi-generator](https://github.com/apple/swift-openapi-generator)? + +With [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator) available (announced WWDC 2023), the path forward became clear: + +1. **Create OpenAPI specification from CloudKit documentation** + - Translate Apple's prose docs → Machine-readable YAML + - Every endpoint, parameter, response type formally defined + +2. **Let swift-openapi-generator generate the client** + - Run `swift build` → 10,476 lines of type-safe networking code appear + - Request/response types (Codable structs) + - API client methods (async/await) + - Type-safe enums, JSON handling, URL building + - Configuration: `openapi-generator-config.yaml` + Swift Package Manager build plugin + +3. **Build clean abstraction layer on top** + - Wrap generated code in friendly, idiomatic Swift API + - Add TokenManager for authentication + - CustomFieldValue for CloudKit's polymorphic types + +By following [spec-driven development](https://brightdigit.com/tutorials/swift-openapi-generator/), we had many benefits: + +- Type safety (if it compiles, it's valid CloudKit usage) +- Completeness (every endpoint defined) +- Maintainability (spec changes = regenerate code) +- No manual JSON parsing or networking boilerplate +- Cross-platform (macOS, iOS, Linux, server-side Swift) + + +### Challenge #1: Type System Polymorphism +[CloudKit fields](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html#//apple_ref/doc/uid/TP40015240-CH3-SW2) are dynamically typed—one field can be STRING, INT64, DOUBLE, TIMESTAMP, BYTES, REFERENCE, ASSET, LOCATION, or LIST. But [OpenAPI is statically typed](https://spec.openapis.org/oas/latest.html). How do we model this polymorphism? + +```no-highlight +Me: "Here's CloudKit's field value structure from Apple's docs. + A field can have value of type STRING, INT64, DOUBLE, TIMESTAMP, + BYTES, REFERENCE, ASSET, LOCATION, LIST..." + +Claude: "This is a discriminated union. Try modeling with oneOf in OpenAPI: + The value property can be oneOf the different types, + and the type field acts as a discriminator." + +Me: "Good start, but there's a CloudKit quirk: ASSETID is different + from ASSET. ASSET has full metadata, ASSETID is just a reference." + +Claude: "Interesting! You'll need a type override in the generator config: + typeOverrides: + schemas: + FieldValue: CustomFieldValue + Then implement CustomFieldValue to handle ASSETID specially." + +Me: "Perfect. Can you generate test cases for all field types?" + +Claude: "Here are test cases for STRING, INT64, DOUBLE, TIMESTAMP, + BYTES, REFERENCE, ASSET, ASSETID, LOCATION, and LIST..." +``` + +Having developed MistKit previously, I understood the challenge of various field types and the difficulty in expressing that in Swift. This is a common challenge in Swift with JSON data. + +Claude's suggestion of [`typeOverrides`](https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/documentation/swift-openapi-generator/configuring-the-generator#Type-overrides) was the breakthrough—instead of fighting OpenAPI's type system, we'd let the generator create basic types, then override with our custom implementation that handles CloudKit's quirks. + +#### Understanding ASSET vs ASSETID + +CloudKit uses two different type discriminators for [asset fields](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html#//apple_ref/doc/uid/TP40015240-CH3-SW2): + +**[ASSET](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html#//apple_ref/doc/uid/TP40015240-CH3-SW2)** - Full asset metadata returned by CloudKit +- Appears in: Query responses, lookup responses, modification responses +- Contains: `fileChecksum`, `size`, `downloadURL`, `wrappingKey`, `receipt` +- Use case: When you need to download or verify the asset file + +**[ASSETID](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html#//apple_ref/doc/uid/TP40015240-CH3-SW2)** - Asset reference placeholder +- Appears in: Record creation/update requests +- Contains: Same structure as ASSET, but typically only `downloadURL` populated +- Use case: When you're referencing an already-uploaded asset + +At the end of the day, both decode to the same `AssetValue` structure, but CloudKit distinguishes them with different type strings (`"ASSET"` vs `"ASSETID"`). Our custom implementation handles this elegantly: + +```swift +internal struct CustomFieldValue: Codable, Hashable, Sendable { + internal enum FieldTypePayload: String, Codable, Sendable { + case asset = "ASSET" + case assetid = "ASSETID" // Both decode to AssetValue + case string = "STRING" + case int64 = "INT64" + // ... more types + } + + internal let value: CustomFieldValuePayload + internal let type: FieldTypePayload? +} +``` + +Using the `CustomFieldValue` with the power of openapi-generator `typeOverides` allows us to implement the specific quirks of CloudKit field values. + + +### Challenge #2: Authentication Complexity + +The next challenge was dealing with the 3 different methods of authentication: + +1. **API Token** - Container-level access + - Query parameter: `ckAPIToken` + - Simplest method + - A starting point for **Web Auth Token** + +2. **[Web Auth Token](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW2)** - User-specific access + - Two query parameters: `ckAPIToken` + `ckWebAuthToken` + - For private database access + +3. **[Server-to-Server](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6)** - Public Database Access + - ECDSA P-256 signature in Authorization header + - Most complex, most secure + + +This became a complexity problem when trying to model it in OpenAPI. What Claude suggested was to use the [ClientMiddleware API](https://swiftpackageindex.com/apple/swift-openapi-runtime/1.8.3/documentation/openapiruntime/clientmiddleware) to handle authentication dynamically rather than relying on generator's built-in auth. The meant we used: + +1. **OpenAPI**: Define all three `securitySchemes` but make endpoint security optional (`security: []`) +2. **Middleware**: Implement `AuthenticationMiddleware` that inspects `TokenManager` at runtime +3. **TokenManager Protocol**: Three implementations (API, WebAuth, ServerToServer) +4. **Runtime Selection**: Client chooses auth method via TokenManager injection + +Here's what it looks like: + +```swift +internal struct AuthenticationMiddleware: ClientMiddleware { + internal let tokenManager: any TokenManager + + func intercept(...) async throws -> (HTTPResponse, HTTPBody?) { + guard let credentials = try await tokenManager.getCurrentCredentials() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) + } + + var modifiedRequest = request + + switch credentials.method { + case .apiToken(let token): + // Add ckAPIToken to query parameters + case .webAuthToken(let apiToken, let webToken): + // Add both tokens to query parameters + case .serverToServer(let keyID, let privateKey): + // Sign request with ECDSA + // Add Authorization header with signature + } + + return try await next(modifiedRequest, body, baseURL) + } +} +``` + +This helps because: + +- ✅ Generator doesn't need to handle auth complexity +- ✅ We control authentication at runtime +- ✅ Easy to test (inject mock TokenManager) +- ✅ Supports all three methods seamlessly +- ✅ Can switch auth methods without code changes + +While this looks like it will work I wanted to make absolute sure. When using an LLM **it's important to have code which actually proves that it works and not just unit test.** So I created a simple command line tool which would to both write and read from a public and private database: + +```no-highlight +Me: "Can you run MistDemo to test the actual functionality?" + +Claude: *[Runs demo successfully]* +"Perfect! The MistDemo ran successfully: + - Web Authentication - Used API token + web auth token + - CloudKit API Calls - Made multiple successful requests + - HTTP Integration - AuthenticationMiddleware properly added all required parameters + - Data Retrieval - Successfully retrieved TodoItem records from CloudKit + + The authentication layer is working as designed. All three authentication methods have a solid foundation!" +``` + +We'll touch more on this approach in [the next blog post.](/tutorials/rebuilding-mistkit-claude-code-part-2/) + + +### Challenge #3: Error Handling + +[CloudKit returns over 9 different HTTP status codes](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html), each with nested error details including `serverErrorCode`, `reason`, `uuid`, and sometimes `redirectURL` or `retryAfter`. What would be nice is if we can parse these in a Swift-y way, taking advantage of Swift 6 features like typed throws for more precise error handling. + +According to Apple's Documentation: + +> **Record Fetch Error Dictionary** +> +> The error dictionary describing a failed operation with the following keys: + + - `recordName`: The name of the record that the operation failed on. + - `reason`: A string indicating the reason for the error. + - `serverErrorCode`: A string containing the code for the error that occurred. For possible values, see Error Codes. + - `retryAfter`: The suggested time to wait before trying this operation again. + - `uuid`: A unique identifier for this error. + - `redirectURL`: A redirect URL for the user to securely sign in. + +Based on this, I had Claude create an openapi entry on this: + +```yaml +components: + schemas: + ErrorResponse: + type: object + description: Error response object + properties: + uuid: + type: string + description: Unique error identifier for support + serverErrorCode: + type: string + enum: + - ACCESS_DENIED + - ATOMIC_ERROR + - AUTHENTICATION_FAILED + - AUTHENTICATION_REQUIRED + - BAD_REQUEST + - CONFLICT + - EXISTS + - INTERNAL_ERROR + - NOT_FOUND + - QUOTA_EXCEEDED + - THROTTLED + - TRY_AGAIN_LATER + - VALIDATING_REFERENCE_ERROR + - ZONE_NOT_FOUND + reason: + type: string + redirectURL: + type: string + + responses: + BadRequest: + description: Bad request (400) - BAD_REQUEST, ATOMIC_ERROR + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + Unauthorized: + description: Unauthorized (401) - AUTHENTICATION_FAILED + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ... additional error responses for 403, 404, 409, 412, 413, 421, 429, 500, 503 +``` + +Claude was able to translate the documentation into: + +1. **Error Code Enum**: Converted prose list of error codes to explicit enum +2. **HTTP Status Mapping**: Created reusable response components for each HTTP status +3. **Consistent Schema**: All errors use same `ErrorResponse` schema +4. **Status Documentation**: Linked HTTP statuses to CloudKit error codes in descriptions + +This enables: +- **Type-Safe Error Handling**: Generated code includes all possible error codes +- **Automatic Deserialization**: Errors automatically parsed to correct type +- **Centralized Definitions**: Define once, reference everywhere + +Here's how it's mapped: + +| HTTP Status | CloudKit Error Codes | Client Action | +|-------------|---------------------|---------------| +| **400 Bad Request** | `BAD_REQUEST`, `ATOMIC_ERROR` | Fix request parameters or retry non-atomically | +| **401 Unauthorized** | `AUTHENTICATION_FAILED` | Re-authenticate or check credentials | +| **403 Forbidden** | `ACCESS_DENIED` | User lacks permissions | +| **404 Not Found** | `NOT_FOUND`, `ZONE_NOT_FOUND` | Verify resource exists | +| **409 Conflict** | `CONFLICT`, `EXISTS` | Fetch latest version and retry, or use force operations | +| **412 Precondition Failed** | `VALIDATING_REFERENCE_ERROR` | Referenced record doesn't exist | +| **413 Request Too Large** | `QUOTA_EXCEEDED` | Reduce request size or upgrade quota | +| **429 Too Many Requests** | `THROTTLED` | Implement exponential backoff | +| **500 Internal Error** | `INTERNAL_ERROR` | Retry with backoff | +| **503 Service Unavailable** | `TRY_AGAIN_LATER` | Temporary issue, retry later | + +This structured [error handling](https://brightdigit.com/articles/swift-error-handling/) enables the generated client to provide specific, actionable error messages rather than generic HTTP failures. Developers get type-safe error codes, HTTP status mapping, and clear guidance on how to handle each error condition. + + +### Challenge #4: API Ergonomics + +The generated OpenAPI client works, but it's not exactly ergonomic. Here's what a simple query looks like with the raw generated code: + +```swift +// Verbose generated API +let input = Operations.queryRecords.Input( + path: .init( + version: "1", + container: "iCloud.com.example.MyApp", + environment: Components.Parameters.environment.production, + database: Components.Parameters.database._private + ), + headers: .init(accept: [.json]), + body: .json(.init( + query: .init(recordType: "User") + )) +) + +let response = try await client.queryRecords(input) + +switch response { +case .ok(let okResponse): + let queryResponse = try okResponse.body.json + // Process records... +default: + // Handle errors... +} +``` + +The problem is there's too much boilerplate for simple operations when we can clean this up with a nicer abstraction. The solution was to build a three-layer architecture that keeps the generated code internal and exposes a clean public API: + +Three-layer architecture showing User Code (public API), MistKit Abstraction (internal), and Generated OpenAPI Client (internal) + +So now it can look something like this: + +```swift +// Clean, idiomatic Swift +let service = try CloudKitService( + container: "iCloud.com.example.MyApp", + environment: .production, + database: .private, + tokenManager: tokenManager +) + +let records = try await service.queryRecords( + recordType: "User", + filter: .equals("status", .string("active")) +) + +// Type-safe field access +for record in records { + if let name = record.fields["name"]?.stringValue { + print("User: \(name)") + } +} +``` + +In this case, we create a few abstraction to help: + +- `FieldValue` enum for type-safe field access +- `RecordInfo` struct for read operations +- `QueryFilter` for building queries +- `CloudKitService` wrapper hiding OpenAPI complexity + +This means the generated code stays internal while users interact with the more friendly API. + + +### The Iterative Workflow with Claude + +This process of building and refining was iterative when working with Claude Code: + +1. **I draft the structure** + - Provide CloudKit domain knowledge and desired API + +2. **Claude expands** + - Fills in request/response schemas + - Generates boilerplate for similar endpoints + - Creates consistent patterns + +3. **I review for CloudKit accuracy** + - Check against Apple docs + - Add edge cases and CloudKit quirks + - Refine error responses + - Define integration and unit tests for verification + +4. **Claude validates consistency** + - Catches schema mismatches + - Suggests improvements + +5. **Iterate until complete** + +Let's take for instance, this conversation I had with Claude: + +```no-highlight +Me: "Here's the query endpoint from Apple's docs" + +Claude: *[Creates complete OpenAPI definition]* +"Here's a complete OpenAPI definition with request/response schemas" + +Me: "Add `resultsLimit` validation and `continuationMarker` for pagination" + +Claude: *[Updates definition with pagination support]* +"Updated, and I noticed the `zoneID` should be optional" +``` + +> youtube https://youtu.be/gH3QnVHsUAc + +By providing my own experience with great Swift APIs and Claude's ability at applying patterns, I quickly build a library that's friendly to use. + +#### Building MistKit from Scratch with Claude Code + +With Claude Code, I could easily create an openapi document based on the Apple's documentation. With my guidance and understanding with the REST API and good Swift design, I could guide Claude through issues like: + +* Field Value with the oneOf pattern and handling the ASSETID quirk) +* completed authentication modeling with three security schemes + +This will make it much easier to continue future features with MistKit and enabling me to create some server-side application for my apps. + + +## What's Next + +After three months of collaboration with Claude (**representing significant acceleration over manual development**), I had: +- ✅ 10,476 lines of generated, type-safe Swift code +- ✅ Three authentication methods working seamlessly +- ✅ CustomFieldValue handling CloudKit's polymorphic types +- ✅ Clean public API hiding OpenAPI complexity +- ✅ 161 tests across 47 test files + +The OpenAPI spec was complete. The generated client compiled. The abstraction layer was elegant. Unit tests passed. + +**How Claude Code Accelerated Development:** +- **Documentation Translation**: Converting Apple's prose documentation to a precise OpenAPI spec would have taken weeks manually. Claude handled the bulk of this in days, with me providing CloudKit domain expertise and corrections. +- **Boilerplate Generation**: The 10,476 lines of generated Swift code from swift-openapi-generator saved months of hand-writing networking code, request/response types, and JSON handling. +- **Pattern Application**: Once I established patterns (like `CustomFieldValue` for polymorphic types), Claude consistently applied them across the codebase. +- **Iteration Speed**: When authentication approaches needed refactoring, Claude could update dozens of files in minutes vs. hours of manual editing. + +What would have likely taken 6-12 months of solo development was compressed into 3 months of _side-project_ collaboration, with Claude handling repetitive tasks while I focused on architecture, CloudKit-specific quirks, and real-world testing. + +However I really needed to put it the test in my actual uses. In the next post, I'll talk about find flaws in MistKit by actually consuming my library with help from Claude Code. I'll be building a couple of command line tools for easily uploading data for [Bushel](https://getbushel.app) and a future RSS Reader to the public database. By doing this I'll understand [Claude's limitation, benefits and how to workaround those.](/tutorials/rebuilding-mistkit-claude-code-part-2/) diff --git a/docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-2.md b/docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-2.md new file mode 100644 index 00000000..129c56ef --- /dev/null +++ b/docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-2.md @@ -0,0 +1,195 @@ +--- +title: Rebuilding MistKit with Claude Code - Real-World Lessons and Collaboration Patterns (Part 2) +date: 2025-12-10 00:00 +description: After building MistKit's type-safe CloudKit client, we put it to the test with real applications. Discover what happened when theory met practice—the unexpected discoveries, hard-earned lessons, and collaboration patterns that emerged from 428 Claude Code sessions over three months. +featuredImage: /media/tutorials/rebuilding-mistkit-claude-code/mistkit-rebuild-part1-hero.webp +subscriptionCTA: Want to learn more about AI-assisted Swift development and modern API design patterns? Sign up for our newsletter to get notified about the rest of the Modern Swift Patterns series and future tutorials on building production-ready Swift applications. +--- + +In [Part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), I showed how [Claude Code](https://claude.ai/claude-code) and [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) transformed [CloudKit's REST documentation](https://developer.apple.com/documentation/cloudkitjs/cloudkit/cloudkit_web_services) into a type-safe Swift client. We had 161 unit tests which passed, but would it actually work in the real world? + +📚 **[View Documentation](https://swiftpackageindex.com/brightdigit/MistKit/documentation)** | 🐙 **[GitHub Repository](https://github.com/brightdigit/MistKit)** + +- [Real-World Proof](#real-world-proof) + - [The Celestra and Bushel Examples](#the-celestra-and-bushel-examples) + - [Integration Testing Through Real Applications](#integration-testing-through-real-applications) +- [Lessons Learned](#lessons-learned) + - [Unit Test Generation](#unit-test-generation) + - [Human Guided Architecture](#human-guided-architecture) + - [Grabby AI](#grabby-ai) + - [Context Management](#context-management) + - [Human + AI Code Reviews](#human--ai-code-reviews) +- [Multiplier, not a Replacement](#multiplier-not-a-replacement) + + +## Real-World Proof + +Would MistKit's abstractions actually work when building an application? I had 2 real-world applications for MistKit to try it out: + +- an RSS aggregator syncing thousands of articles to CloudKit using [SyndiKit](https://github.com/brightdigit/SyndiKit) for an app codenamed **[Celestra](https://celestr.app)** +- For **[Bushel](https://getbushel.app)**, I wanted to track restore images and various metadata for macOS and developer software versions. + + + +### The Celestra and Bushel Examples + +Tests validate correctness, but real applications validate design. MistKit needed to prove it could power actual software and not just pass unit tests. Enter two real-world applications—**[the Celestra app](https://celestr.app)** (an RSS reader) and **[the Bushel app](https://getbushel.app)** (a macOS virtualization tool)—each powered by MistKit-driven CLI backends that populate CloudKit public databases. These CLI tools, running on scheduled cloud infrastructure, proved MistKit works in production. + +The architecture for both follows the same pattern: +- **Consumer apps** ([the Celestra app](https://celestr.app), [the Bushel app](https://getbushel.app)) - iOS/macOS apps that read from CloudKit +- **CLI tools** - Built with MistKit, run on cloud infrastructure (cron jobs, cloud functions, scheduled tasks) +- **CloudKit public database** - Central data layer connecting CLI tools to apps + +This pattern enables: +- **Automated updates**: CLI tools run on schedules without user devices being online +- **Separation of concerns**: Data population (CLI) vs data consumption (app) +- **Scalability**: Cloud infrastructure handles data aggregation, apps stay lightweight + +#### Celestra: Automated RSS Feed Sync for a Reader App + +The [Celestra app](https://celestr.app) is an RSS reader in development for iOS and macOS. To keep content fresh without requiring the app to be open, I built a [CLI tool with MistKit](https://github.com/brightdigit/MistKit/tree/main/Examples/Celestra) that runs on scheduled cloud infrastructure. The CLI tool runs periodically (cron job, cloud function, scheduled task) to fetch RSS feeds and sync them to CloudKit's public database, making fresh content available to all users instantly—even when their devices are offline. + +This architecture enables push notifications on updated articles without the app running, and MistKit's batch operations can efficiently handle hundreds of content updates. The [CLI tool example](https://github.com/brightdigit/MistKit/tree/main/Examples/Celestra) demonstrates key MistKit patterns: + +**Query filtering** - Find feeds that need updating: +```swift +// Query filtering - find stale feeds +QueryFilter.lessThan("lastAttempted", .date(cutoff)) +QueryFilter.greaterThanOrEquals("usageCount", .int64(minPop)) +``` + +**Batch operations** - Efficiently sync hundreds of articles: +```swift +// Batch operations +let operations = articles.map { article in + RecordOperation.create( + recordType: "Article", + recordName: article.guid, + fields: article.toCloudKitFields() + ) +} +service.modifyRecords(operations, atomic: false) +``` + +#### Bushel: Powering a macOS VM App with CloudKit + +The [Bushel app](https://getbushel.app) is a macOS virtualization tool for developers. It currently allows pluggable _hubs_ to get a list of restore images, their download URLs, and their status. However, since the data is universal, I wanted a comprehensive, queryable central database of macOS restore images and various metadata about operating system versions and developer tools. Therefore I wanted a [CLI tool with MistKit](https://github.com/brightdigit/MistKit/tree/main/Examples/Bushel) that runs on scheduled cloud infrastructure (cron jobs, cloud functions, scheduled tasks) to populate a CloudKit public database with various metadata about macOS versions and their restore images. + +This architecture provides: +- **Public Database**: Worldwide access to version history without embedding static JSON in the app +- **Automated Updates**: CLI tool syncs latest info on restore images, Xcode, and Swift versions +- **Queryable**: [Bushel app](https://getbushel.app) can easily query for restore images such as _macOS 15.2_ +- **Scalable**: CLI tool aggregates data from various sources automatically +- **Deduplication**: buildNumber-based deduplication ensures clean data + +The [CLI tool example](https://github.com/brightdigit/MistKit/tree/main/Examples/Bushel) demonstrates advanced MistKit patterns: + +```swift +// Protocol-based record conversion +protocol CloudKitRecord { + static var cloudKitRecordType: String { get } + func toCloudKitFields() -> [String: FieldValue] +} + +// Relationship handling +fields["minimumMacOS"] = .reference( + Reference(recordName: restoreImageRecordName) +) +``` + +--- + +> transistor https://share.transistor.fm/s/ffcb9fc1 + +Both CLI tool examples serve as copy-paste starting points for new MistKit projects. + + +Watching MistKit power real applications was satisfying—I could see the generated code actually work in production. The CLI tools successfully synced RSS articles (Celestra) and tracked complex version relationships (Bushel). But more importantly, these real-world applications revealed critical issues that no unit test could catch. + + +### Integration Testing Through Real Applications + +Building real applications exposed issues no unit test could catch. Here's what Celestra and Bushel revealed: + +- **Batch Operation Limits**: CloudKit enforces 200-operation-per-request limit (not documented clearly) therefore we added chunking logic. +- **Boolean Field Handling**: CloudKit has no native boolean type but we wanted the developer to safely use Swift Boolean types so we created a safe and easy way to use the standardized INT64 representation. + +I was able to verify that the API design and critical pieces like Server-to-Server authentication were working as intended. These real-world tests validated MistKit's foundation. + + + +## Lessons Learned + +There were few things which surprised me as far as what Claude Code was good and not good at and where [collaboration worked best](https://brightdigit.com/tutorials/syntaxkit-swift-code-generation/). Claude excelled at tasks I thought would be hard (comprehensive test generation). It struggled with things I assumed would be easy (knowing which APIs exist). Through iteration, a collaboration pattern emerged. Here's what I learned. + +### Unit Test Generation + +Test generation proved to be Claude Code's greatest strength, and it's ideal for LLMs because it leverages pattern recognition from vast training data. Claude has seen thousands of Swift test files and can apply those structural patterns to new domains. The task requires extrapolation from examples, not novel reasoning—Claude recognizes "if testing STRING type, also test empty string, Unicode, and nil" because that pattern appears frequently in its training corpus. A typical interaction looked like this: + +```no-highlight +Me: "Generate tests for all CustomFieldValue types" + +Claude: *[Creates tests covering all 10 field types with edge cases]* +"I've generated comprehensive tests covering: + - All 10 field types (STRING, INT64, DOUBLE, TIMESTAMP, BYTES, REFERENCE, ASSET, ASSETID, LOCATION, LIST) + - Edge cases: empty lists, nil values, malformed data + - Encoding/decoding round-trips + - Nested LIST types + - Invalid type/value combinations + + I also added tests for the ASSET vs ASSETID quirk" +``` + +It was able to produce 161 tests across 47 files including edge cases I hadn't considered. The only quirk I found was that it favored [XCTest](https://developer.apple.com/documentation/xctest) over [Swift Testing](https://developer.apple.com/documentation/testing) at first. This makes sense since there's probably more training material in XCTest. I've primarily switched to Swift Testing for my new work. If you are in the same place then be sure to make a note of that in your `CLAUDE.md` when you start your project. + +### Human Guided Architecture + +While Claude excelled at pattern-based tasks, architectural decisions consistently required human judgment. At various points, Claude would steer the architecture in strange directions that didn't seem correct. The issue is that its training is best for smaller contexts and code examples, which isn't enough for holistic system design. Be confident in steering Claude in the right direction—this is where developer expertise matters most. The risk is drift if the pattern isn't perfectly specified, but for well-defined transformations, LLMs excel. Luckily, Claude does a fairly good job at refactoring when corrected, and its context window (200K tokens in Sonnet 4.5) allows it to see multiple files simultaneously and apply consistent transformations across the codebase. + +### Grabby AI + +These limitations manifested in predictable patterns throughout the project. As we were implementing the CLI tools for Bushel and Celestra, Claude would often try to implement features using the direct [OpenAPI](https://www.openapis.org/) code as opposed to the abstracted API we had built: + +```swift +// WRONG: Internal type reference +let operation = Components.Schemas.RecordOperation( + recordType: "RestoreImage", + fields: fields +) +``` + +Even going so far as to make those methods and properties `public`. Often referred to as power-grabbing, it would go outside its designated boundary, even though I would tell it often not to use those APIs. It's important to set those constraints clearly within the context window and review the code intentionally. All mistakes share common traits—Claude follows patterns from training data or generated code literally without questioning ergonomics or existence. The fix is always the same: explicit guidance in prompts and immediate verification of suggestions. + +### Context Management + +Managing these challenges required strategic context management. One of the biggest challenges working with Claude Code is managing its knowledge cutoffs and lack of familiarity with newer or niche APIs. In the world of Swift, Claude's training often predates [Swift Testing](https://developer.apple.com/documentation/testing) or [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) specifics. This is where providing documentation upfront in `.claude/docs/` helps. With tools like [Sosumi.ai](https://sosumi.ai) for Apple API exploration and [llm.codes](https://llm.codes) I can provide documentation like: +- `testing-enablinganddisabling.md` (126KB) - Swift Testing patterns +- `webservices.md` (289KB) - CloudKit Web Services REST API reference +- `cloudkitjs.md` (188KB) - CloudKit operation patterns and data types +- `swift-openapi-generator.md` (235KB) - Code generation configuration + +> youtube https://youtu.be/gH3QnVHsUAc + +At the root of this is the `CLAUDE.md` file which acts as a table of contents, telling Claude where to look for specific information. Claude doesn't need to memorize everything—it needs to know where to look. + +### Human + AI Code Reviews + +Whatever your AI writes should be understood by you fairly well. Don't skip this step. This is especially important in the context of [humane code](https://brightdigit.com/articles/humane-code/)—code that is empathetic to future developers who need to understand and maintain it. AI-generated code still needs to communicate clearly with the humans who will work with it later. + +> transistor https://share.transistor.fm/s/99f236b1 + +These patterns and practices reflect a deeper truth about AI-assisted development: Claude Code is a force multiplier, not a replacement for developer judgment. I provided architectural vision; Claude generated comprehensive implementations. I identified edge cases from domain knowledge; Claude translated them into exhaustive test suites. I steered strategic decisions; Claude handled mechanical transformations at scale. Together, we built something neither could have built alone—a production-ready CloudKit client that balances type safety with developer ergonomics. + + +## Multiplier, not a Replacement + +These lessons crystallized into a philosophy: **AI is a force multiplier, not a replacement**. Claude generated thousands of lines of code, but I architected what those lines should accomplish. It drafted comprehensive tests, but I knew which edge cases mattered. It refactored at scale, but I chose the patterns worth preserving. Where I lacked expertise translating CloudKit's REST API into an OpenAPI spec, Claude filled those gaps. + +The proof came from real-world application. Building **Celestra** and **Bushel** validated MistKit's design beyond what unit tests could achieve. The CLI tools exposed batch operation limits, revealed boolean field handling quirks, and confirmed that Server-to-Server authentication worked in production. These discoveries transformed MistKit from a technically correct library into a production-ready tool. + +Both CLI examples are now open source as starting points for new projects: +- [Bushel CLI Example](https://github.com/brightdigit/MistKit/tree/main/Examples/Bushel) - Demonstrates complex CloudKit relationships and batch operations powering the [Bushel app](https://getbushel.app) +- [Celestra CLI Example](https://github.com/brightdigit/MistKit/tree/main/Examples/Celestra) - Demonstrates public database patterns and automated sync for the [Celestra app](https://celestr.app) + +Through 428 sessions across three months, Claude Code and I built MistKit v1.0 Alpha—a type-safe CloudKit client that proves AI-assisted development can deliver production-quality Swift libraries when human judgment guides the process. + diff --git a/docs/internals/authentication-middleware.md b/docs/internals/authentication-middleware.md new file mode 100644 index 00000000..7354521b --- /dev/null +++ b/docs/internals/authentication-middleware.md @@ -0,0 +1,349 @@ +# Authentication Middleware + +MistKit's authentication system uses an HTTP middleware pattern to transparently sign every request with the correct credentials, supporting three authentication methods and runtime upgrades between them. + +## TokenManager Protocol + +A `TokenManager` is the lifecycle owner of credentials (loading, validating, rotating, persisting). It vends an `Authenticator` to whomever needs to apply those credentials to an outgoing request: + +```swift +public protocol TokenManager: Sendable { + var hasCredentials: Bool { get async } + func validateCredentials() async throws(TokenManagerError) -> Bool + func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? +} +``` + +Concrete managers include `APITokenManager`, `WebAuthTokenManager`, `ServerToServerAuthManager`, and the runtime-upgradable `AdaptiveTokenManager`. + +## Authenticator Protocol + +Each concrete `Authenticator` (`APITokenAuthenticator`, `WebAuthTokenAuthenticator`, `ServerToServerAuthenticator`) owns both the credential payload and the rule for attaching it to a request: + +```swift +public protocol Authenticator: Sendable { + static var storageKey: String { get } + var defaultStorageIdentifier: String { get } + init(decoding data: Data) throws + func authenticate(request: inout HTTPRequest, body: inout HTTPBody?) async throws + func encoded() throws -> Data +} +``` + +Bundling the credential with the application logic keeps new authentication schemes from rippling into the middleware: any `Authenticator` can be plugged in without changes elsewhere. + +### Why `body: inout HTTPBody?` + +`HTTPBody` is a single-pass async sequence. `ServerToServerAuthenticator` has to read every byte to compute the SHA-256 over the body — and that consumes the iterator. The authenticator buffers those bytes, then reassigns `body = HTTPBody(bytes)` so downstream middleware sees a fresh, replayable copy of the same data. The protocol's `inout` parameter exists to allow that reassignment. The other authenticators don't actually mutate `body`, but the protocol signature has to accommodate the one that does. + +## The Middleware Intercept + +`AuthenticationMiddleware` conforms to the OpenAPI `ClientMiddleware` protocol and intercepts every outgoing request. The middleware itself is trivial — it asks the token manager for the current authenticator and lets the authenticator apply itself: + +```swift +internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) +) async throws -> (HTTPResponse, HTTPBody?) { + guard let authenticator = try await tokenManager.currentAuthenticator() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) + } + + var modifiedRequest = request + var modifiedBody = body + try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) + return try await next(modifiedRequest, modifiedBody, baseURL) +} +``` + +The per-scheme branching — query parameter for API token, two query parameters for web auth, signed headers for server-to-server — lives inside each concrete `Authenticator.authenticate(request:body:)` implementation. + +## API Token Authentication + +The simplest method — appends a query parameter. `APITokenAuthenticator.authenticate(request:body:)` does the work: + +1. **Append the query parameter** — `?ckAPIToken=<64-hex>` is added to the request URL. + +Example: + +``` +GET /database/1/iCloud.com.example/development/public/records/query?ckAPIToken=abc123... +``` + +- Token is a 64-character hex string identifying the container. +- Grants **public database** access only. +- Validated via regex: `^[a-f0-9]{64}$` + +## Web Auth Token Authentication + +Adds a second query parameter for user-specific operations. `WebAuthTokenAuthenticator.authenticate(request:body:)` does the work: + +1. **URL-encode the web auth token** via `CharacterMapEncoder` to escape characters CloudKit rejects in query strings. +2. **Append both query parameters** — `?ckAPIToken=<…>&ckWebAuthToken=` is added to the request URL. + +Example: + +``` +GET ...?ckAPIToken=abc123...&ckWebAuthToken=encoded-token +``` + +The encoder replaces URL-unsafe characters: + +```swift +// CharacterMapEncoder replaces URL-unsafe characters: +// + → %2B +// / → %2F +// = → %3D +let encoded = tokenEncoder.encode(webToken) +``` + +This grants access to **private and shared databases** for the authenticated user. + +## Server-to-Server (ECDSA P-256) Authentication + +Used for backend services without user interaction. `ServerToServerAuthenticator.authenticate(request:body:)` does the work: + +1. **Buffer the request body** (up to `bodyBufferLimit`, default 1 MiB), and reassign `body` to the buffered copy so downstream middleware sees the same bytes the signature covers. +2. **Build a `RequestSignature`** — this initializer does the signing. +3. **Append the resulting `HTTPFields`** to `request.headerFields`. + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + let bodyData: Data? + if let original = body { + let bytes = try await Data(collecting: original, upTo: bodyBufferLimit) + body = HTTPBody(bytes) + bodyData = bytes + } else { + bodyData = nil + } + + let signature = try RequestSignature( + keyID: keyID, + privateKey: privateKey, + requestBody: bodyData, + webServiceURL: request.path ?? "" + ) + + request.headerFields.append(contentsOf: signature.headers) +} +``` + +### RequestSignature + +`RequestSignature` is the value type that holds a signed header bundle: + +```swift +public struct RequestSignature: Sendable { + public let keyID: String + public let iso8601DateString: String // exact string that was signed + public let signatureDerRepresentation: Data // DER bytes + public var signatureBase64: String { ... } // wire form, derived on demand + public var headers: HTTPFields { ... } // typed headers, ready to append +} +``` + +It's a transport-format value, not a domain value: + +- **`iso8601DateString` is stored as String, not Date.** The ISO 8601 string is part of the signed payload — re-formatting a `Date` on every header access would risk a wire string that differs from what was signed (formatter options, locale, fractional seconds). Storing the string locks the wire form to the signed form. +- **`signatureDerRepresentation` is stored as Data, not String.** The ECDSA signature is naturally bytes. The base64 form is computed on demand via `signatureBase64` so the type doesn't carry a redundant encoding, and the struct stays free of the `@available` constraints that come with `P256.Signing.ECDSASignature`. + +### Signing process + +The convenience initializer `init(keyID:privateKey:requestBody:webServiceURL:date:)` does: + +1. **Format the ISO 8601 date.** On macOS 12 / iOS 15 / tvOS 15 / watchOS 8 and later, `Date.ISO8601FormatStyle` (Sendable value type). On older OSes, a `nonisolated(unsafe)` cached `ISO8601DateFormatter` (documented thread-safe for `string(from:)`). +2. **Hash the body.** `SHA256.cloudKitBodyHash(of: body)` returns `base64(SHA256(body))`, or the empty string when the body is `nil` — matching CloudKit's no-body convention. +3. **Build the signing payload:** `"::"` +4. **Sign with P-256.** `privateKey.signature(for: Data(payload.utf8))` → DER bytes. +5. **Delegate to the storage init**, capturing `iso8601DateString`, `signatureDerRepresentation`, and `keyID`. + +A second initializer — `init(keyID:privateKey:bodyHash:webServiceURL:iso8601DateString:)` — takes the pre-formatted strings directly. It's the core signing path (no formatting, no hashing); the convenience init delegates to it. Useful for deterministic testing or when the caller already has those values. + +### Wire format + +The three headers appended to the request: + +```http +X-Apple-CloudKit-Request-KeyID: +X-Apple-CloudKit-Request-ISO8601Date: 2026-05-15T14:30:00Z +X-Apple-CloudKit-Request-SignatureV1: +``` + +`HTTPField.Name` constants for these live in `Sources/MistKit/Utilities/HTTPField.Name+CloudKit.swift`. + +## AdaptiveTokenManager & `upgradeToWebAuthentication` + +`AdaptiveTokenManager` is an **actor** that enables runtime transitions between auth methods. It vends an `APITokenAuthenticator` while API-only and switches to a `WebAuthTokenAuthenticator` once upgraded: + +```swift +public actor AdaptiveTokenManager: TokenManager { + internal let apiToken: String + internal var webAuthToken: String? + internal let storage: (any TokenStorage)? + + public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + if let webToken = webAuthToken { + return try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webToken) + } + return try APITokenAuthenticator(token: apiToken) + } + + @discardableResult + public func upgradeToWebAuthentication( + webAuthToken: String + ) async throws(TokenManagerError) -> WebAuthTokenAuthenticator { + let authenticator = try WebAuthTokenAuthenticator( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + self.webAuthToken = webAuthToken + + if let storage = storage { + // Don't fail the upgrade if storage fails — just log. + try? await storage.store(authenticator, identifier: apiToken) + } + + return authenticator + } +} +``` + +`WebAuthTokenAuthenticator`'s initializer is what validates the token (empty / too-short tokens throw `TokenManagerError.invalidCredentials`), so the manager doesn't duplicate that logic. The companion `downgradeToAPIOnly()` and `updateWebAuthToken(_:)` methods live alongside on `AdaptiveTokenManager+Transitions`. + +A typical client-app flow: + +1. App starts with **API token only** → can query public database. +2. User authenticates via CloudKit's web auth flow → receives web auth token. +3. App calls `upgradeToWebAuthentication(webAuthToken:)` → all subsequent requests include the user's token. +4. App can now access **private database** operations. + +The actor ensures thread-safe state mutation; the optional `TokenStorage` lets credentials survive across app launches. + +## Per-Call Attribution: `PublicAuthPreference` + +Public-database operations can be attributed either to a service account (server-to-server / ECDSA P-256) or to an end user (API token + web auth). The caller picks per-call via `Database.public(_:)`: + +```swift +public enum Database { + case `public`(PublicAuthPreference) + case `private` + case shared +} +``` + +- `.prefers(.serverToServer)` — try S2S, fall back to web-auth/API-token if S2S isn't configured. +- `.prefers(.webAuth)` — try web-auth, fall back to S2S. +- `.requires(.serverToServer)` — must use S2S, otherwise throw `missingCredentials(.preferenceRequired)`. +- `.requires(.webAuth)` — must use web-auth, otherwise throw. + +There is **no default** — every public-database call picks explicitly. User-context routes (`/users/*`) pass `.public(.requires(.webAuth))` directly because CloudKit only accepts web-auth on those endpoints. Private and shared databases ignore this — they always require web-auth, since CloudKit rejects S2S on those scopes. + +See `Sources/MistKit/Authentication/PublicAuthPreference.swift` and `Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift` for the resolution logic. + +## Complete Authentication Flow + +The shared middleware pipeline is the same regardless of scheme — the per-scheme work happens inside `Authenticator.authenticate(request:body:)`, expanded in the diagrams that follow. + +```mermaid +sequenceDiagram + autonumber + participant App as App / Operation call + participant Client as OpenAPI Client + participant Mid as AuthenticationMiddleware + participant TM as TokenManager + participant Auth as Authenticator + participant Net as next middleware / URLSession + participant CK as CloudKit + + App->>Client: queryRecords(...) / createRecord(...) / ... + Client->>Mid: intercept(request, body, next) + Mid->>TM: currentAuthenticator() + + alt no credentials + TM-->>Mid: nil + Mid-->>Client: throws TokenManagerError.noCredentialsAvailable + else has credentials + TM-->>Mid: (any Authenticator) + Mid->>Auth: authenticate(&request, &body) + Note over Auth: scheme-specific work —
see per-scheme diagrams below + Auth-->>Mid: (request and body now carry credentials) + Mid->>Net: next(request, body, baseURL) + Net->>CK: HTTPS request + CK-->>Net: HTTP response + Net-->>Mid: (response, body) + Mid-->>Client: (response, body) + Client-->>App: decoded result + end +``` + +### API Token Flow + +The `APITokenAuthenticator` branch is a single mutation — it appends `?ckAPIToken=<64-hex>` to the request URL and returns. No body buffering, no async work. + +### Web Auth Flow + +`WebAuthTokenAuthenticator` URL-encodes the user-specific token via `CharacterMapEncoder` before appending it as a second query parameter alongside the API token: + +```mermaid +sequenceDiagram + autonumber + participant Mid as AuthenticationMiddleware + participant Auth as WebAuthTokenAuthenticator + participant Enc as CharacterMapEncoder + + Mid->>Auth: authenticate(&request, &body) + Auth->>Enc: encode(webAuthToken) + Note right of Enc: + → %2B
/ → %2F
= → %3D + Enc-->>Auth: URL-encoded token + Note over Auth: attach credentials to request + Auth->>Auth: append ?ckAPIToken=<…>&ckWebAuthToken= + Auth-->>Mid: request carries query params +``` + +### Server-to-Server (ECDSA P-256) Flow + +`ServerToServerAuthenticator` buffers the body so it can be hashed, builds a `RequestSignature`, and appends the three `X-Apple-CloudKit-*` headers: + +```mermaid +sequenceDiagram + autonumber + participant Mid as AuthenticationMiddleware + participant Auth as ServerToServerAuthenticator + participant Sig as RequestSignature + + Mid->>Auth: authenticate(&request, &body) + Auth->>Auth: buffer body and reassign as replayable HTTPBody + Auth->>Sig: RequestSignature(keyID, privateKey, requestBody, webServiceURL) + Sig-->>Auth: signed header bundle + Note over Auth: attach credentials to request + Auth->>Auth: request.headerFields.append(contentsOf: signature.headers) + Auth-->>Mid: request carries X-Apple-CloudKit-* headers +``` + +### Token Manager Selection + +```mermaid +flowchart LR + Cfg["Credentials / config"] --> Choose{which manager?} + Choose -- "API token only" --> APITM["APITokenManager"] + Choose -- "API + web auth token" --> WATM["WebAuthTokenManager"] + Choose -- "keyID + P-256 key" --> S2STM["ServerToServerAuthManager"] + Choose -- "API token now,
maybe upgrade later" --> Adapt["AdaptiveTokenManager (actor)"] + + APITM --> APIA["APITokenAuthenticator"] + WATM --> WAA["WebAuthTokenAuthenticator"] + S2STM --> S2SA["ServerToServerAuthenticator"] + Adapt -. "before upgrade" .-> APIA + Adapt -. "after upgradeToWebAuthentication(_:)" .-> WAA + + APIA --> Pub["Public DB (user-attributed)"] + WAA --> PrivShared["Private / Shared DB"] + S2SA --> PubS2S["Public DB (service-attributed)"] +``` diff --git a/docs/internals/error-code-parsing.md b/docs/internals/error-code-parsing.md new file mode 100644 index 00000000..7468a37c --- /dev/null +++ b/docs/internals/error-code-parsing.md @@ -0,0 +1,263 @@ +# Error Code Parsing + +MistKit transforms CloudKit's HTTP error responses into strongly-typed Swift errors through a multi-stage pipeline that leverages the OpenAPI-generated types. + +## CloudKit Error Response Format + +CloudKit returns errors as JSON with a consistent structure: + +```json +{ + "uuid": "a1b2c3d4-...", + "serverErrorCode": "AUTHENTICATION_FAILED", + "reason": "The request requires authentication." +} +``` + +The `serverErrorCode` is one of 14 defined values: + +| Code | Meaning | +|------|---------| +| `ACCESS_DENIED` | User lacks permission | +| `ATOMIC_ERROR` | Atomic operation partially failed | +| `AUTHENTICATION_FAILED` | Invalid credentials | +| `AUTHENTICATION_REQUIRED` | No credentials provided | +| `BAD_REQUEST` | Malformed request | +| `CONFLICT` | Record version conflict | +| `EXISTS` | Record already exists | +| `INTERNAL_ERROR` | Server-side failure | +| `NOT_FOUND` | Record/zone not found | +| `QUOTA_EXCEEDED` | Storage/request quota hit | +| `THROTTLED` | Rate limited | +| `TRY_AGAIN_LATER` | Temporary server issue | +| `VALIDATING_REFERENCE_ERROR` | Reference integrity violation | +| `ZONE_NOT_FOUND` | Zone doesn't exist | + +## OpenAPI Schema Definition + +The `openapi.yaml` defines an `ErrorResponse` schema and maps it to HTTP status codes: + +```yaml +ErrorResponse: + properties: + uuid: { type: string } + serverErrorCode: + type: string + enum: [ACCESS_DENIED, ATOMIC_ERROR, AUTHENTICATION_FAILED, ...] + reason: { type: string } + redirectURL: { type: string } +``` + +Each HTTP status (400, 401, 403, 404, 409, 412, 413, 421, 429, 500, 503) gets its own response type referencing this schema. + +## Generated Types + +The Swift OpenAPI generator produces: + +```swift +// The error code enum +internal enum serverErrorCodePayload: String, Codable, CaseIterable { + case ACCESS_DENIED, ATOMIC_ERROR, AUTHENTICATION_FAILED, ... +} + +// The error response struct +internal struct ErrorResponse: Codable { + internal var uuid: String? + internal var serverErrorCode: serverErrorCodePayload? + internal var reason: String? + internal var redirectURL: String? +} + +// Per-status response wrappers +// Components.Responses.BadRequest, .Unauthorized, .Forbidden, etc. +``` + +Each operation's output is an enum with success and error cases: + +```swift +enum Operations.queryRecords.Output { + case ok(Operations.queryRecords.Output.Ok) + case badRequest(Components.Responses.BadRequest) + case unauthorized(Components.Responses.Unauthorized) + case forbidden(Components.Responses.Forbidden) + // ... all 11 error cases + case undocumented(statusCode: Int, ...) +} +``` + +## CloudKitResponseType Protocol + +A protocol provides unified error extraction across all operation outputs: + +```swift +protocol CloudKitResponseType { + var badRequestResponse: Components.Responses.BadRequest? { get } + var unauthorizedResponse: Components.Responses.Unauthorized? { get } + var forbiddenResponse: Components.Responses.Forbidden? { get } + var notFoundResponse: Components.Responses.NotFound? { get } + var conflictResponse: Components.Responses.Conflict? { get } + // ... all 11 error statuses + var isOk: Bool { get } + var undocumentedStatusCode: Int? { get } +} +``` + +Each operation output implements this via pattern matching: + +```swift +extension Operations.queryRecords.Output: CloudKitResponseType { + var badRequestResponse: Components.Responses.BadRequest? { + if case .badRequest(let response) = self { return response } + return nil + } + // ... one property per error case +} +``` + +## The Public Error Type: `CloudKitError` + +```swift +public enum CloudKitError: LocalizedError, Sendable { + case httpError(statusCode: Int) + case httpErrorWithDetails(statusCode: Int, serverErrorCode: String?, reason: String?) + case httpErrorWithRawResponse(statusCode: Int, rawResponse: String) + case invalidResponse + case underlyingError(any Error) + case decodingError(DecodingError) + case networkError(URLError) +} +``` + +The primary case is `httpErrorWithDetails` — it carries both the HTTP status and the CloudKit-specific error code and reason string. + +## The Parsing Pipeline + +### Step 1: Extractor Array + +`CloudKitError+OpenAPI.swift` defines an ordered list of extractors: + +```swift +private static let errorExtractors: [(any CloudKitResponseType) -> CloudKitError?] = [ + { $0.badRequestResponse.map { CloudKitError(badRequest: $0) } }, + { $0.unauthorizedResponse.map { CloudKitError(unauthorized: $0) } }, + { $0.forbiddenResponse.map { CloudKitError(forbidden: $0) } }, + { $0.notFoundResponse.map { CloudKitError(notFound: $0) } }, + { $0.conflictResponse.map { CloudKitError(conflict: $0) } }, + // ... all 11 statuses +] +``` + +### Step 2: Generic Initializer + +```swift +internal init?(_ response: T) { + if response.isOk { return nil } // Not an error — return nil + + // Try each extractor until one matches + for extractor in Self.errorExtractors { + if let error = extractor(response) { + self = error + return + } + } + + // Undocumented status code fallback + if let statusCode = response.undocumentedStatusCode { + self = .httpError(statusCode: statusCode) + return + } + + self = .invalidResponse +} +``` + +### Step 3: Per-Status Initializers + +Each status code has a private initializer that extracts the JSON body: + +```swift +private init(badRequest response: Components.Responses.BadRequest) { + if case .json(let errorResponse) = response.body { + self = .httpErrorWithDetails( + statusCode: 400, + serverErrorCode: errorResponse.serverErrorCode?.rawValue, + reason: errorResponse.reason + ) + } else { + self = .httpError(statusCode: 400) + } +} +``` + +If the body isn't JSON (rare), it falls back to a plain `httpError` without details. + +## Response Processing Pattern + +`CloudKitResponseProcessor` applies the error-first pattern to every operation: + +```swift +func processQueryResponse(_ response: Operations.queryRecords.Output) + async throws(CloudKitError) -> [RecordInfo] +{ + // Error check FIRST + if let error = CloudKitError(response) { + throw error + } + + // Only then extract the success payload + switch response { + case .ok(let okResponse): + return try extractRecords(from: okResponse) + default: + throw CloudKitError.invalidResponse + } +} +``` + +## Additional Error Mapping + +`CloudKitService+ErrorHandling.swift` catches non-HTTP errors and wraps them: + +```swift +func mapToCloudKitError(_ error: any Error) -> CloudKitError { + switch error { + case let cloudKitError as CloudKitError: + return cloudKitError // Already typed — pass through + case let decodingError as DecodingError: + return .decodingError(decodingError) + case let urlError as URLError: + return .networkError(urlError) + default: + return .underlyingError(error) + } +} +``` + +## End-to-End Example + +``` +HTTP 400 Bad Request +{"serverErrorCode": "BAD_REQUEST", "reason": "Invalid filter"} + │ + ▼ +OpenAPI runtime deserializes to: + Operations.queryRecords.Output.badRequest(Components.Responses.BadRequest) + │ + ▼ +CloudKitResponseProcessor calls: CloudKitError(response) + │ + ▼ +Generic initializer: response.isOk == false + → tries errorExtractors[0]: badRequestResponse != nil ✓ + │ + ▼ +Private init(badRequest:): extracts JSON body + → .httpErrorWithDetails(statusCode: 400, + serverErrorCode: "BAD_REQUEST", + reason: "Invalid filter") + │ + ▼ +Thrown as CloudKitError — caller can switch on case + or display via .errorDescription: + "CloudKit API error: HTTP 400\nServer Error Code: BAD_REQUEST\nReason: Invalid filter" +``` diff --git a/docs/internals/field-type-polymorphism.md b/docs/internals/field-type-polymorphism.md new file mode 100644 index 00000000..b40de2b7 --- /dev/null +++ b/docs/internals/field-type-polymorphism.md @@ -0,0 +1,198 @@ +# Field Type Polymorphism + +MistKit models CloudKit field values through a three-layer type system that bridges Swift's type safety with the CloudKit REST API's loosely-typed JSON. + +## Domain Layer: The `FieldValue` Enum + +At the public API level, `FieldValue` is a discriminated union (Swift enum) with 9 cases: + +```swift +public enum FieldValue: Codable, Equatable, Sendable { + case string(String) + case int64(Int) + case double(Double) + case bytes(String) // Base64-encoded binary data + case date(Date) // Stored as milliseconds since epoch + case location(Location) + case reference(Reference) + case asset(Asset) + case list([FieldValue]) // Recursive — supports heterogeneous lists +} +``` + +This is the only type library consumers interact with. It hides all API serialization details. + +## OpenAPI Layer: Request/Response Asymmetry + +CloudKit's REST API treats field values differently in requests vs responses. The OpenAPI spec (`openapi.yaml`) models this with two separate schemas: + +### FieldValueRequest + +```yaml +FieldValueRequest: + properties: + value: + oneOf: [StringValue, Int64Value, DoubleValue, BytesValue, + DateValue, LocationValue, ReferenceValue, AssetValue, ListValue] + type: + enum: [STRING_LIST, INT64_LIST, DOUBLE_LIST, ...] # List element types only +``` + +- The `type` field is **optional** and only used for list-typed filter expressions (IN/NOT_IN). +- For mutations, CloudKit **infers** the type from the value's JSON structure. + +### FieldValueResponse + +```yaml +FieldValueResponse: + properties: + value: + oneOf: [StringValue, Int64Value, DoubleValue, BytesValue, + DateValue, LocationValue, ReferenceValue, AssetValue, ListValue] + type: + enum: [STRING, INT64, DOUBLE, TIMESTAMP, ASSETID, ...] # All field types +``` + +- The `type` field is **optional but present** — provides explicit type information. +- Critical for disambiguation: a `DoubleValue` with `type: TIMESTAMP` is a date, not a double. + +### Why Two Types? + +| Concern | Request | Response | +|---------|---------|----------| +| Type field purpose | Specifies list element type for filters | Disambiguates value semantics | +| Type field values | List types only (STRING_LIST, etc.) | All field types (STRING, TIMESTAMP, etc.) | +| Required? | No — CloudKit infers from structure | No — but aids parsing | + +Modeling this asymmetry at the schema level means the Swift compiler prevents accidentally using a response type where a request is expected. + +## Generated Code: `oneOf` Polymorphism + +The Swift OpenAPI generator translates `oneOf` into nested enums with try-catch decoding: + +```swift +internal struct FieldValueRequest: Codable { + internal enum valuePayload: Codable { + case StringValue(String) + case Int64Value(Int64) + case DoubleValue(Double) + case DateValue(Double) + case LocationValue(LocationValue) + // ... all 9 cases + + internal init(from decoder: any Decoder) throws { + // Tries each case sequentially — first successful decode wins + if let v = try? container.decode(String.self) { self = .StringValue(v); return } + if let v = try? container.decode(Int64.self) { self = .Int64Value(v); return } + // ... + throw DecodingError.failedToDecodeOneOfSchema(...) + } + } +} +``` + +No discriminator field is needed in the JSON — the generator relies on structural matching. + +## Bidirectional Conversion + +### Domain → Request (`Components.Schemas.FieldValueRequest+MistKit.swift`) + +```swift +internal init(from fieldValue: FieldValue) { + if let scalar = Self.makeScalarRequest(from: fieldValue) { + self = scalar + } else { + self = Self.makeComplexRequest(from: fieldValue) + } +} +``` + +Scalar conversion handles the simple cases (string, int64, double, bytes, date). Complex conversion handles location, reference, asset, and list. Date values are converted from `Date` to milliseconds: + +```swift +case .date(let value): + return Self(value: .DateValue(value.timeIntervalSince1970 * 1_000)) +``` + +### Response → Domain (`FieldValue+Components.swift`) + +```swift +private static func makeSimpleFieldValue( + from value: Components.Schemas.FieldValueResponse.valuePayload, + type fieldType: Components.Schemas.FieldValueResponse._typePayload? +) -> FieldValue? { + if case .DoubleValue(let dblVal) = value { + // The type field disambiguates double vs timestamp + return fieldType == .TIMESTAMP + ? .date(Date(timeIntervalSince1970: dblVal / 1_000)) + : .double(dblVal) + } + // ... +} +``` + +The response `type` field is essential here — without it, a timestamp would be indistinguishable from a plain double. + +## Known Gap: Asset Schema Is Not Split + +Unlike `FieldValue`, the `AssetValue` schema is **not** split into request/response variants. A single `AssetValue` is referenced from both `FieldValueRequest` and `FieldValueResponse` (`openapi.yaml:1016`), and the domain-level `Asset` struct mirrors that — all fields are optional on a single type. + +### Why this is a gap + +CloudKit's asset payload is semantically asymmetric, but the schema doesn't enforce it: + +| Field | Request side (write) | Response side (read) | +|-------|----------------------|----------------------| +| `receipt` | Required — token from prior CDN upload | Not returned | +| `wrappingKey`, `referenceChecksum` | Set by the upload step | Not returned | +| `downloadURL` | Ignored if sent | Required — where to fetch bytes | +| `fileChecksum`, `size` | Optional metadata | Returned by CloudKit | + +Because `AssetValue` flattens both shapes into one all-optional struct: + +- The compiler cannot prevent putting a `downloadURL` into a write payload, or expecting a `receipt` on a read. +- A response asset and a request asset are the same Swift type, so callers can accidentally round-trip the wrong shape. + +### How we address it in practice + +Rather than splitting the schema, the asymmetry is handled at the **service layer**: + +1. **Two-step upload flow** (`CloudKitService+WriteOperations.swift`) hides raw asset request construction from callers. `uploadAssets()` calls `requestAssetUploadURL()` → `uploadAssetData()` → produces an `Asset` populated with `receipt`/`wrappingKey`/`size` ready for a follow-up `modifyRecords` call. Callers don't hand-build write-side `Asset` values. +2. **Read-side `Asset` values** are constructed in `FieldValue+Components.swift` from `FieldValueResponse`, populated only with the fields CloudKit actually returns (`downloadURL`, `fileChecksum`, `size`). +3. **Convention over compilation:** when consuming code needs to act on a download URL, it pattern-matches `case .asset(let asset)` and reads `asset.downloadURL`. The unused write-side fields are simply `nil`. + +### Future work + +Splitting `AssetValue` → `AssetValueRequest` + `AssetValueResponse` in `openapi.yaml` (mirroring the `FieldValueRequest`/`FieldValueResponse` split) would push this asymmetry into the type system. The blocker is that `Asset` is a single public domain type — splitting it would either require two domain types (breaking the 9-case enum symmetry) or a domain-level distinction the public API currently doesn't make. + +## Recursive List Handling + +Lists use `ListValuePayload` — structurally identical to `valuePayload` — enabling nested heterogeneous lists: + +```swift +extension Components.Schemas.ListValuePayload { + internal init(from fieldValue: FieldValue) { + // Same scalar/complex split, recursively applied to each element + } +} +``` + +## Summary + +``` +┌─────────────────────────────────────────────┐ +│ Public API: FieldValue (9-case enum) │ +└──────────────────┬──────────────────────────┘ + │ Bidirectional conversion + ┌───────────┴───────────┐ + ▼ ▼ +┌──────────────┐ ┌───────────────┐ +│ FieldValue │ │ FieldValue │ +│ Request │ │ Response │ +│ (no type) │ │ (+ type hint) │ +└──────────────┘ └───────────────┘ + │ │ + └───────────┬───────────┘ + ▼ + CloudKit REST API (JSON) +``` diff --git a/docs/talk-feedback.md b/docs/talk-feedback.md new file mode 100644 index 00000000..d58b3988 --- /dev/null +++ b/docs/talk-feedback.md @@ -0,0 +1,63 @@ +# Talk Feedback — CloudKit as Your Backend (dry run, 2026-05-05) + +Notes from the Riverside dry run with Evan and Josh. The Keynote deck lives outside the repo, so this file is the durable home for talk-level feedback. Sibling to [`why-mistkit.md`](why-mistkit.md). + +## Source + +- Riverside dry run with Evan + Josh +- Raw transcript: [`transcriptions/transcript.txt`](transcriptions/transcript.txt) +- Self-reported deck completeness during the run: ~60% + +## What's Working + +- The **Heart Witch** Apple-Watch origin story (no login on a watch face → CloudKit) is the single best hook in the deck. +- The **"two and a half authentication methods"** framing is sharper than Apple's three-equal-methods presentation. API Token alone barely qualifies as a method — it's a prerequisite. +- The **GitHub Actions / Bushel / Celestra deployment story** is the strongest section. It's the part of the talk that does not exist in Apple's docs anywhere. +- The **CloudKit Dashboard walkthrough** (Tokens & Keys → openssl command → paste public key → done) is concrete and audience-friendly. + +## Structural Changes + +- **Open with Heart Witch.** Currently it shows up roughly five minutes in. Lead with the problem ("watch user can't type a password"), not company background. +- **Make the public-vs-private + auth-method a 2D matrix slide**, not a bullet list. It's the structure people will remember. +- **Move the deployment / GitHub Actions section earlier** and give it more time. It's defensible content; the intro is not. +- **Fold API Token into the Web Auth Token section.** "Two and a half" is honest framing for the intro, but a full slide on it is overkill — it's a prerequisite, not a peer method. + +## Cuts + +- **General CloudKit / NoSQL intro** — covered by the Part 1 article; assume the audience. +- **MistKit origin / Claude-Code rebuild deep dive** — that's Part 1/2 article territory; one slide max. +- **Field-type polymorphism deep dive** — same; ~30 seconds. +- **Error-handling deep dive** — same; ~30 seconds. +- **WASM / browser-extension tangent** — off-topic for backend services. Replace with one line: "running in a browser? Use CloudKit JS, not MistKit." +- **Roadmap / "what's next" closing** — the Part 2 article covers it; keep one slide for "where to follow along." + +## Expand / Add + +- **`CKFetchWebAuthTokenOperation`** — the iOS-app-to-backend handoff path. Audience members building iOS+server stacks will ask about it in Q&A. At minimum one slide saying "the other way to get a web auth token is from inside an iOS app via `CKFetchWebAuthTokenOperation`; haven't shipped this pattern personally but here's the documented flow." +- **The signing payload format** — the talk hand-waves "Claude figured it out from the docs." Show the canonical string (HTTP method + ISO 8601 timestamp + SHA-256 body hash + path) and the `Authorization` header format. Pull straight from `Sources/MistKit/Service/AuthenticationMiddleware.swift`. +- **Web-auth-token lifetime / refresh** — one bullet. If unknown, spend 15 minutes in the dashboard before the live talk. + +## Audio / Delivery Cleanup (for the recorded version) + +Lines from the transcript to clean up: + +- "Sorry, just going into Do Not Disturb mode." +- "I hate Teams." +- "Surprised? I mean, I know they have an app." +- Multiple "sorry, slides aren't done" asides — replace with confidence in the recorded take. + +## Brand / Spelling + +The auto-transcription introduces several errors that would propagate if the transcript is fed back into Claude as source material: + +- "Heart Witch" → mangled as "Hart Twitch" / "Hardwitch" throughout. Confirm the on-screen spelling and the slide title before recording. +- "MistKit" → consistently transcribed as "Miskit." Search-and-replace before reuse. +- Around the WASM tangent (line 135), "WASM" gets transcribed as "awesome." + +## Q&A Prep — Likely Audience Questions + +- **"How do I get a web auth token from inside my iOS app?"** → `CKFetchWebAuthTokenOperation`. (See *Expand* above.) +- **"Can I use this from a browser extension?"** → Yes for non-Safari, but use CloudKit JS unless you specifically need Swift. +- **"What's the production story for key storage?"** → GitHub Actions Secrets in Bushel / Celestra; secrets manager or env-var injection in general. +- **"Does this work on Linux?"** → Yes — that's the whole point. Also Windows and Android. Not WASM yet (no transport). +- **"How does this compare to using Vapor + the CloudKit framework?"** → The CloudKit framework only runs on Apple platforms. MistKit runs anywhere Swift runs. diff --git a/docs/transcriptions/paragraphs.json b/docs/transcriptions/paragraphs.json new file mode 100644 index 00000000..55b33754 --- /dev/null +++ b/docs/transcriptions/paragraphs.json @@ -0,0 +1 @@ +{"paragraphs":[{"text":"Hey, Evan, can you hear me all right? Yeah, I can hear you. Awesome. How do I sound? Good.","start":262980,"end":268740,"confidence":0.99658203,"words":[{"text":"Hey,","start":262980,"end":263180,"confidence":0.99658203,"speaker":"A"},{"text":"Evan,","start":263180,"end":263580,"confidence":0.99609375,"speaker":"A"},{"text":"can","start":263580,"end":263700,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":263700,"end":263780,"confidence":0.99316406,"speaker":"A"},{"text":"hear","start":263780,"end":263900,"confidence":1,"speaker":"A"},{"text":"me","start":263900,"end":264020,"confidence":1,"speaker":"A"},{"text":"all","start":264020,"end":264140,"confidence":0.87158203,"speaker":"A"},{"text":"right?","start":264140,"end":264420,"confidence":0.96240234,"speaker":"A"},{"text":"Yeah,","start":264660,"end":265020,"confidence":0.9741211,"speaker":"B"},{"text":"I","start":265020,"end":265140,"confidence":1,"speaker":"B"},{"text":"can","start":265140,"end":265260,"confidence":1,"speaker":"B"},{"text":"hear","start":265260,"end":265420,"confidence":1,"speaker":"B"},{"text":"you.","start":265420,"end":265700,"confidence":0.99365234,"speaker":"B"},{"text":"Awesome.","start":266420,"end":267060,"confidence":0.9998372,"speaker":"A"},{"text":"How","start":267060,"end":267340,"confidence":1,"speaker":"A"},{"text":"do","start":267340,"end":267500,"confidence":1,"speaker":"A"},{"text":"I","start":267500,"end":267660,"confidence":1,"speaker":"A"},{"text":"sound?","start":267660,"end":268020,"confidence":0.99975586,"speaker":"A"},{"text":"Good.","start":268340,"end":268740,"confidence":0.99902344,"speaker":"A"}]},{"text":"I've used this microphone in ages. It's like all dusty.","start":270260,"end":274420,"confidence":0.7714844,"words":[{"text":"I've","start":270260,"end":270740,"confidence":0.7714844,"speaker":"A"},{"text":"used","start":270740,"end":270940,"confidence":0.99316406,"speaker":"A"},{"text":"this","start":270940,"end":271140,"confidence":0.9736328,"speaker":"A"},{"text":"microphone","start":271140,"end":271660,"confidence":0.9484375,"speaker":"A"},{"text":"in","start":271660,"end":271820,"confidence":0.9946289,"speaker":"A"},{"text":"ages.","start":271820,"end":272340,"confidence":0.9995117,"speaker":"A"},{"text":"It's","start":273060,"end":273420,"confidence":0.99397784,"speaker":"A"},{"text":"like","start":273420,"end":273580,"confidence":0.99121094,"speaker":"A"},{"text":"all","start":273580,"end":273780,"confidence":0.98583984,"speaker":"A"},{"text":"dusty.","start":273780,"end":274420,"confidence":0.99934894,"speaker":"A"}]},{"text":"How you think I should wait like five minutes for people to come in or. Probably. Yeah, that there's if. Yeah, otherwise you can just. You could start, but that'll be interesting.","start":281140,"end":291930,"confidence":0.6699219,"words":[{"text":"How","start":281140,"end":281500,"confidence":0.6699219,"speaker":"A"},{"text":"you","start":281500,"end":281700,"confidence":0.97021484,"speaker":"A"},{"text":"think","start":281700,"end":281820,"confidence":1,"speaker":"A"},{"text":"I","start":281820,"end":281940,"confidence":0.99853516,"speaker":"A"},{"text":"should","start":281940,"end":282060,"confidence":0.9995117,"speaker":"A"},{"text":"wait","start":282060,"end":282260,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":282260,"end":282380,"confidence":0.99316406,"speaker":"A"},{"text":"five","start":282380,"end":282540,"confidence":0.9995117,"speaker":"A"},{"text":"minutes","start":282540,"end":282820,"confidence":1,"speaker":"A"},{"text":"for","start":282820,"end":283020,"confidence":0.9995117,"speaker":"A"},{"text":"people","start":283020,"end":283220,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":283220,"end":283380,"confidence":0.9916992,"speaker":"A"},{"text":"come","start":283380,"end":283540,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":283540,"end":283780,"confidence":0.99902344,"speaker":"A"},{"text":"or.","start":283780,"end":284100,"confidence":0.9394531,"speaker":"A"},{"text":"Probably.","start":284260,"end":284740,"confidence":0.8670247,"speaker":"B"},{"text":"Yeah,","start":284980,"end":285460,"confidence":0.99316406,"speaker":"B"},{"text":"that","start":285770,"end":285970,"confidence":0.72314453,"speaker":"B"},{"text":"there's","start":285970,"end":286410,"confidence":0.8248698,"speaker":"B"},{"text":"if.","start":286490,"end":286890,"confidence":0.97558594,"speaker":"B"},{"text":"Yeah,","start":286970,"end":287530,"confidence":0.99869794,"speaker":"B"},{"text":"otherwise","start":288010,"end":288450,"confidence":0.98502606,"speaker":"B"},{"text":"you","start":288450,"end":288570,"confidence":0.99902344,"speaker":"B"},{"text":"can","start":288570,"end":288690,"confidence":0.99902344,"speaker":"B"},{"text":"just.","start":288690,"end":288890,"confidence":1,"speaker":"B"},{"text":"You","start":288890,"end":289090,"confidence":0.99609375,"speaker":"B"},{"text":"could","start":289090,"end":289290,"confidence":0.9824219,"speaker":"B"},{"text":"start,","start":289290,"end":289610,"confidence":0.9995117,"speaker":"B"},{"text":"but","start":289850,"end":290250,"confidence":0.99902344,"speaker":"B"},{"text":"that'll","start":291130,"end":291530,"confidence":0.96761066,"speaker":"B"},{"text":"be","start":291530,"end":291610,"confidence":0.9995117,"speaker":"B"},{"text":"interesting.","start":291610,"end":291930,"confidence":0.99609375,"speaker":"B"}]},{"text":"Do you mind if I grab a cup of coffee real quick? No, not at all. Not at all. Okay, cool. I'm not using the AirPods mic, so I can hear you, but you won't be able to hear me.","start":291930,"end":301370,"confidence":0.7919922,"words":[{"text":"Do","start":291930,"end":292090,"confidence":0.7919922,"speaker":"A"},{"text":"you","start":292090,"end":292170,"confidence":0.99560547,"speaker":"A"},{"text":"mind","start":292170,"end":292290,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":292290,"end":292450,"confidence":0.99560547,"speaker":"A"},{"text":"I","start":292450,"end":292650,"confidence":0.9995117,"speaker":"A"},{"text":"grab","start":292650,"end":292930,"confidence":1,"speaker":"A"},{"text":"a","start":292930,"end":293050,"confidence":0.9995117,"speaker":"A"},{"text":"cup","start":293050,"end":293170,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":293170,"end":293330,"confidence":0.9970703,"speaker":"A"},{"text":"coffee","start":293330,"end":293650,"confidence":0.9998372,"speaker":"A"},{"text":"real","start":293650,"end":293810,"confidence":0.9995117,"speaker":"A"},{"text":"quick?","start":293810,"end":294010,"confidence":1,"speaker":"A"},{"text":"No,","start":294010,"end":294250,"confidence":0.9975586,"speaker":"B"},{"text":"not","start":294250,"end":294450,"confidence":1,"speaker":"B"},{"text":"at","start":294450,"end":294570,"confidence":0.9995117,"speaker":"B"},{"text":"all.","start":294570,"end":294730,"confidence":1,"speaker":"B"},{"text":"Not","start":294730,"end":294930,"confidence":0.71875,"speaker":"A"},{"text":"at","start":294930,"end":295010,"confidence":0.8486328,"speaker":"A"},{"text":"all.","start":295010,"end":295210,"confidence":0.9042969,"speaker":"A"},{"text":"Okay,","start":295530,"end":296090,"confidence":0.9946289,"speaker":"A"},{"text":"cool.","start":296730,"end":297210,"confidence":0.99609375,"speaker":"A"},{"text":"I'm","start":297210,"end":297570,"confidence":0.8929036,"speaker":"A"},{"text":"not","start":297570,"end":297730,"confidence":1,"speaker":"A"},{"text":"using","start":297730,"end":297930,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":297930,"end":298090,"confidence":0.99609375,"speaker":"A"},{"text":"AirPods","start":298090,"end":298610,"confidence":0.96594,"speaker":"A"},{"text":"mic,","start":298610,"end":298930,"confidence":0.9863281,"speaker":"A"},{"text":"so","start":298930,"end":299250,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":299250,"end":299490,"confidence":1,"speaker":"A"},{"text":"can","start":299490,"end":299650,"confidence":0.9995117,"speaker":"A"},{"text":"hear","start":299650,"end":299810,"confidence":1,"speaker":"A"},{"text":"you,","start":299810,"end":299970,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":299970,"end":300130,"confidence":1,"speaker":"A"},{"text":"you","start":300130,"end":300290,"confidence":1,"speaker":"A"},{"text":"won't","start":300290,"end":300490,"confidence":0.9998372,"speaker":"A"},{"text":"be","start":300490,"end":300570,"confidence":1,"speaker":"A"},{"text":"able","start":300570,"end":300690,"confidence":1,"speaker":"A"},{"text":"to","start":300690,"end":300850,"confidence":1,"speaker":"A"},{"text":"hear","start":300850,"end":301050,"confidence":0.9995117,"speaker":"A"},{"text":"me.","start":301050,"end":301370,"confidence":0.9995117,"speaker":"A"}]},{"text":"Okay.","start":301690,"end":302250,"confidence":0.98746747,"words":[{"text":"Okay.","start":301690,"end":302250,"confidence":0.98746747,"speaker":"B"}]},{"text":"It's.","start":362440,"end":387820,"confidence":0.7732747,"words":[{"text":"It's.","start":362440,"end":387820,"confidence":0.7732747,"speaker":"A"}]},{"text":"Thank you for your patience.","start":531699,"end":535060,"confidence":0.9851074,"words":[{"text":"Thank","start":531699,"end":531940,"confidence":0.9851074,"speaker":"A"},{"text":"you","start":531940,"end":532260,"confidence":1,"speaker":"A"},{"text":"for","start":533860,"end":534220,"confidence":0.59277344,"speaker":"A"},{"text":"your","start":534220,"end":534500,"confidence":1,"speaker":"A"},{"text":"patience.","start":534500,"end":535060,"confidence":0.9992676,"speaker":"A"}]},{"text":"So is it just you? It looks like it's just me. Josh is trying to get in, but he's trying to get on on his mobile device and I don't think that's possible with Riverside.","start":549010,"end":559250,"confidence":0.9873047,"words":[{"text":"So","start":549010,"end":549130,"confidence":0.9873047,"speaker":"A"},{"text":"is","start":549130,"end":549290,"confidence":0.99365234,"speaker":"A"},{"text":"it","start":549290,"end":549450,"confidence":0.99902344,"speaker":"A"},{"text":"just","start":549450,"end":549650,"confidence":1,"speaker":"A"},{"text":"you?","start":549650,"end":549970,"confidence":0.9995117,"speaker":"A"},{"text":"It","start":551330,"end":551610,"confidence":0.95751953,"speaker":"B"},{"text":"looks","start":551610,"end":551810,"confidence":1,"speaker":"B"},{"text":"like","start":551810,"end":551930,"confidence":0.9995117,"speaker":"B"},{"text":"it's","start":551930,"end":552130,"confidence":0.9996745,"speaker":"B"},{"text":"just","start":552130,"end":552290,"confidence":1,"speaker":"B"},{"text":"me.","start":552290,"end":552570,"confidence":1,"speaker":"B"},{"text":"Josh","start":552570,"end":553010,"confidence":0.9995117,"speaker":"B"},{"text":"is","start":553010,"end":553290,"confidence":0.9970703,"speaker":"B"},{"text":"trying","start":553290,"end":553530,"confidence":0.9995117,"speaker":"B"},{"text":"to","start":553530,"end":553650,"confidence":1,"speaker":"B"},{"text":"get","start":553650,"end":553810,"confidence":1,"speaker":"B"},{"text":"in,","start":553810,"end":554010,"confidence":0.9995117,"speaker":"B"},{"text":"but","start":554010,"end":554170,"confidence":0.9995117,"speaker":"B"},{"text":"he's","start":554170,"end":554610,"confidence":0.92529297,"speaker":"B"},{"text":"trying","start":554610,"end":554930,"confidence":0.9995117,"speaker":"B"},{"text":"to","start":554930,"end":555090,"confidence":1,"speaker":"B"},{"text":"get","start":555090,"end":555210,"confidence":1,"speaker":"B"},{"text":"on","start":555210,"end":555490,"confidence":0.9272461,"speaker":"B"},{"text":"on","start":555650,"end":555970,"confidence":1,"speaker":"B"},{"text":"his","start":555970,"end":556210,"confidence":0.99902344,"speaker":"B"},{"text":"mobile","start":556210,"end":556530,"confidence":0.9998372,"speaker":"B"},{"text":"device","start":556530,"end":556810,"confidence":1,"speaker":"B"},{"text":"and","start":556810,"end":557010,"confidence":0.90478516,"speaker":"B"},{"text":"I","start":557010,"end":557210,"confidence":1,"speaker":"B"},{"text":"don't","start":557210,"end":557490,"confidence":0.98828125,"speaker":"B"},{"text":"think","start":557490,"end":557689,"confidence":1,"speaker":"B"},{"text":"that's","start":557689,"end":558010,"confidence":1,"speaker":"B"},{"text":"possible","start":558010,"end":558290,"confidence":1,"speaker":"B"},{"text":"with","start":558290,"end":558570,"confidence":0.9995117,"speaker":"B"},{"text":"Riverside.","start":558570,"end":559250,"confidence":0.9998372,"speaker":"B"}]},{"text":"Surprised? I mean, I know they have an app. Maybe he's using. I'm not sure if he's using. Using the app or not.","start":563250,"end":570070,"confidence":0.9345703,"words":[{"text":"Surprised?","start":563250,"end":563890,"confidence":0.9345703,"speaker":"A"},{"text":"I","start":564690,"end":564970,"confidence":0.9897461,"speaker":"A"},{"text":"mean,","start":564970,"end":565090,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":565090,"end":565210,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":565210,"end":565370,"confidence":1,"speaker":"A"},{"text":"they","start":565370,"end":565530,"confidence":1,"speaker":"A"},{"text":"have","start":565530,"end":565690,"confidence":1,"speaker":"A"},{"text":"an","start":565690,"end":565850,"confidence":0.99902344,"speaker":"A"},{"text":"app.","start":565850,"end":566130,"confidence":0.9863281,"speaker":"A"},{"text":"Maybe","start":567590,"end":567790,"confidence":0.93359375,"speaker":"B"},{"text":"he's","start":567790,"end":567990,"confidence":0.9996745,"speaker":"B"},{"text":"using.","start":567990,"end":568190,"confidence":0.99902344,"speaker":"B"},{"text":"I'm","start":568190,"end":568430,"confidence":0.99934894,"speaker":"B"},{"text":"not","start":568430,"end":568510,"confidence":0.99902344,"speaker":"B"},{"text":"sure","start":568510,"end":568630,"confidence":1,"speaker":"B"},{"text":"if","start":568630,"end":568710,"confidence":0.9980469,"speaker":"B"},{"text":"he's","start":568710,"end":568790,"confidence":0.9189453,"speaker":"B"},{"text":"using.","start":568790,"end":569030,"confidence":0.98535156,"speaker":"B"},{"text":"Using","start":569110,"end":569430,"confidence":1,"speaker":"B"},{"text":"the","start":569430,"end":569630,"confidence":0.99902344,"speaker":"B"},{"text":"app","start":569630,"end":569790,"confidence":0.9995117,"speaker":"B"},{"text":"or","start":569790,"end":569910,"confidence":0.9995117,"speaker":"B"},{"text":"not.","start":569910,"end":570070,"confidence":0.9995117,"speaker":"B"}]},{"text":"Okay.","start":570070,"end":570550,"confidence":0.99820966,"words":[{"text":"Okay.","start":570070,"end":570550,"confidence":0.99820966,"speaker":"A"}]},{"text":"Should I just go? Sure. Okay. Well, thanks for joining me, Evan. I really appreciate it.","start":575190,"end":585270,"confidence":0.99658203,"words":[{"text":"Should","start":575190,"end":575470,"confidence":0.99658203,"speaker":"A"},{"text":"I","start":575470,"end":575630,"confidence":0.8354492,"speaker":"A"},{"text":"just","start":575630,"end":575910,"confidence":1,"speaker":"A"},{"text":"go?","start":575910,"end":576310,"confidence":1,"speaker":"A"},{"text":"Sure.","start":578230,"end":578630,"confidence":1,"speaker":"B"},{"text":"Okay.","start":579830,"end":580470,"confidence":0.91015625,"speaker":"A"},{"text":"Well,","start":582390,"end":582710,"confidence":0.9980469,"speaker":"A"},{"text":"thanks","start":582710,"end":583030,"confidence":0.9926758,"speaker":"A"},{"text":"for","start":583030,"end":583230,"confidence":1,"speaker":"A"},{"text":"joining","start":583230,"end":583549,"confidence":0.75911456,"speaker":"A"},{"text":"me,","start":583549,"end":583830,"confidence":0.99902344,"speaker":"A"},{"text":"Evan.","start":583830,"end":584310,"confidence":0.9511719,"speaker":"A"},{"text":"I","start":584310,"end":584510,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":584510,"end":584670,"confidence":0.9995117,"speaker":"A"},{"text":"appreciate","start":584670,"end":584990,"confidence":0.9088135,"speaker":"A"},{"text":"it.","start":584990,"end":585270,"confidence":0.99853516,"speaker":"A"}]},{"text":"I would say no. I mean I do, seriously. So yeah, this is a kind of a dry run. I would say I'm about 60% done with this presentation about CloudKit on the server and we'll probably hop back and forth between Keynote and not Keynote, but yeah. So this is CloudKit as your backend from iOS to server side Swift.","start":587430,"end":616630,"confidence":0.8666992,"words":[{"text":"I","start":587430,"end":587670,"confidence":0.8666992,"speaker":"A"},{"text":"would","start":587670,"end":587790,"confidence":0.67871094,"speaker":"A"},{"text":"say","start":587790,"end":588070,"confidence":0.9448242,"speaker":"A"},{"text":"no.","start":588390,"end":588630,"confidence":0.9951172,"speaker":"A"},{"text":"I","start":588630,"end":588710,"confidence":0.9995117,"speaker":"A"},{"text":"mean","start":588710,"end":588830,"confidence":0.95947266,"speaker":"A"},{"text":"I","start":588830,"end":588990,"confidence":0.99902344,"speaker":"A"},{"text":"do,","start":588990,"end":589270,"confidence":1,"speaker":"A"},{"text":"seriously.","start":589270,"end":589910,"confidence":0.99934894,"speaker":"A"},{"text":"So","start":591830,"end":592110,"confidence":0.9995117,"speaker":"A"},{"text":"yeah,","start":592110,"end":592470,"confidence":1,"speaker":"A"},{"text":"this","start":592630,"end":592910,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":592910,"end":593030,"confidence":0.79296875,"speaker":"A"},{"text":"a","start":593030,"end":593150,"confidence":0.6645508,"speaker":"A"},{"text":"kind","start":593150,"end":593310,"confidence":0.99853516,"speaker":"A"},{"text":"of","start":593310,"end":593430,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":593430,"end":593550,"confidence":0.99609375,"speaker":"A"},{"text":"dry","start":593550,"end":593830,"confidence":0.8828125,"speaker":"A"},{"text":"run.","start":593830,"end":594150,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":594710,"end":594830,"confidence":0.9941406,"speaker":"A"},{"text":"would","start":594830,"end":594950,"confidence":0.9980469,"speaker":"A"},{"text":"say","start":594950,"end":595070,"confidence":0.99560547,"speaker":"A"},{"text":"I'm","start":595070,"end":595270,"confidence":0.99869794,"speaker":"A"},{"text":"about","start":595270,"end":595470,"confidence":0.9995117,"speaker":"A"},{"text":"60%","start":595470,"end":596110,"confidence":0.92505,"speaker":"A"},{"text":"done","start":596110,"end":596350,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":596350,"end":596510,"confidence":1,"speaker":"A"},{"text":"this","start":596510,"end":596710,"confidence":0.99853516,"speaker":"A"},{"text":"presentation","start":596710,"end":597350,"confidence":1,"speaker":"A"},{"text":"about","start":599270,"end":599670,"confidence":0.9975586,"speaker":"A"},{"text":"CloudKit","start":600310,"end":600990,"confidence":0.7687988,"speaker":"A"},{"text":"on","start":600990,"end":601150,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":601150,"end":601310,"confidence":0.9946289,"speaker":"A"},{"text":"server","start":601310,"end":601750,"confidence":0.7963867,"speaker":"A"},{"text":"and","start":604070,"end":604470,"confidence":0.9892578,"speaker":"A"},{"text":"we'll","start":604870,"end":605230,"confidence":0.9514974,"speaker":"A"},{"text":"probably","start":605230,"end":605470,"confidence":1,"speaker":"A"},{"text":"hop","start":605470,"end":605710,"confidence":0.9946289,"speaker":"A"},{"text":"back","start":605710,"end":605950,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":605950,"end":606110,"confidence":1,"speaker":"A"},{"text":"forth","start":606110,"end":606350,"confidence":1,"speaker":"A"},{"text":"between","start":606350,"end":606630,"confidence":1,"speaker":"A"},{"text":"Keynote","start":606630,"end":607230,"confidence":0.88049316,"speaker":"A"},{"text":"and","start":607230,"end":607390,"confidence":0.9975586,"speaker":"A"},{"text":"not","start":607390,"end":607590,"confidence":0.9458008,"speaker":"A"},{"text":"Keynote,","start":607590,"end":608310,"confidence":0.99328613,"speaker":"A"},{"text":"but","start":608870,"end":609270,"confidence":0.9941406,"speaker":"A"},{"text":"yeah.","start":609510,"end":609990,"confidence":0.9737956,"speaker":"A"},{"text":"So","start":611670,"end":611950,"confidence":0.9946289,"speaker":"A"},{"text":"this","start":611950,"end":612110,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":612110,"end":612310,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":612310,"end":612910,"confidence":0.92456055,"speaker":"A"},{"text":"as","start":612910,"end":613070,"confidence":0.9863281,"speaker":"A"},{"text":"your","start":613070,"end":613230,"confidence":0.94628906,"speaker":"A"},{"text":"backend","start":613230,"end":613750,"confidence":0.8310547,"speaker":"A"},{"text":"from","start":613910,"end":614310,"confidence":1,"speaker":"A"},{"text":"iOS","start":614310,"end":614870,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":615030,"end":615390,"confidence":0.9941406,"speaker":"A"},{"text":"server","start":615390,"end":615830,"confidence":0.9873047,"speaker":"A"},{"text":"side","start":615830,"end":616070,"confidence":0.5727539,"speaker":"A"},{"text":"Swift.","start":616070,"end":616630,"confidence":0.9953613,"speaker":"A"}]},{"text":"So what is CloudKit? CloudKit is a service launched by Apple probably a decade ago to kind of give developers a built in back end for storing data for their apps. One of the biggest benefits is is how cheap it is to use for iOS developers.","start":627600,"end":649970,"confidence":0.9916992,"words":[{"text":"So","start":627600,"end":627840,"confidence":0.9916992,"speaker":"A"},{"text":"what","start":628160,"end":628480,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":628480,"end":628720,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit?","start":628720,"end":629440,"confidence":0.88281,"speaker":"A"},{"text":"CloudKit","start":629600,"end":630320,"confidence":0.88281,"speaker":"A"},{"text":"is","start":630320,"end":630600,"confidence":0.9921875,"speaker":"A"},{"text":"a","start":630600,"end":630880,"confidence":0.99853516,"speaker":"A"},{"text":"service","start":630880,"end":631200,"confidence":0.9995117,"speaker":"A"},{"text":"launched","start":632240,"end":632680,"confidence":0.99731445,"speaker":"A"},{"text":"by","start":632680,"end":632840,"confidence":1,"speaker":"A"},{"text":"Apple","start":632840,"end":633360,"confidence":1,"speaker":"A"},{"text":"probably","start":633600,"end":634000,"confidence":0.99869794,"speaker":"A"},{"text":"a","start":634000,"end":634160,"confidence":0.9995117,"speaker":"A"},{"text":"decade","start":634160,"end":634520,"confidence":0.99975586,"speaker":"A"},{"text":"ago","start":634520,"end":634800,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":635920,"end":636279,"confidence":0.9848633,"speaker":"A"},{"text":"kind","start":636279,"end":636520,"confidence":0.8803711,"speaker":"A"},{"text":"of","start":636520,"end":636800,"confidence":0.98828125,"speaker":"A"},{"text":"give","start":636960,"end":637360,"confidence":0.9995117,"speaker":"A"},{"text":"developers","start":638880,"end":639680,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":639840,"end":640200,"confidence":0.99902344,"speaker":"A"},{"text":"built","start":640200,"end":640520,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":640520,"end":640720,"confidence":0.99316406,"speaker":"A"},{"text":"back","start":640720,"end":641000,"confidence":0.9995117,"speaker":"A"},{"text":"end","start":641000,"end":641280,"confidence":0.58935547,"speaker":"A"},{"text":"for","start":641280,"end":641520,"confidence":0.99609375,"speaker":"A"},{"text":"storing","start":641520,"end":641960,"confidence":0.9946289,"speaker":"A"},{"text":"data","start":641960,"end":642240,"confidence":0.99902344,"speaker":"A"},{"text":"for","start":642640,"end":642920,"confidence":0.9995117,"speaker":"A"},{"text":"their","start":642920,"end":643160,"confidence":0.99853516,"speaker":"A"},{"text":"apps.","start":643160,"end":643680,"confidence":0.99902344,"speaker":"A"},{"text":"One","start":644480,"end":644760,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":644760,"end":644880,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":644880,"end":645000,"confidence":0.99853516,"speaker":"A"},{"text":"biggest","start":645000,"end":645360,"confidence":1,"speaker":"A"},{"text":"benefits","start":645360,"end":646000,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":646080,"end":646300,"confidence":0.84765625,"speaker":"A"},{"text":"is","start":646450,"end":646690,"confidence":0.9736328,"speaker":"A"},{"text":"how","start":646690,"end":647090,"confidence":0.9995117,"speaker":"A"},{"text":"cheap","start":647090,"end":647450,"confidence":0.9998372,"speaker":"A"},{"text":"it","start":647450,"end":647610,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":647610,"end":647890,"confidence":0.9980469,"speaker":"A"},{"text":"to","start":647970,"end":648250,"confidence":0.99853516,"speaker":"A"},{"text":"use","start":648250,"end":648490,"confidence":0.9970703,"speaker":"A"},{"text":"for","start":648490,"end":648810,"confidence":0.9995117,"speaker":"A"},{"text":"iOS","start":648810,"end":649290,"confidence":0.9992676,"speaker":"A"},{"text":"developers.","start":649290,"end":649970,"confidence":0.998291,"speaker":"A"}]},{"text":"So if you have built an app, you could just add CloudKit right here within the Xcode project and use the regular CloudKit API in Swift to go ahead and start using it in your app.","start":652450,"end":670850,"confidence":0.95751953,"words":[{"text":"So","start":652450,"end":652850,"confidence":0.95751953,"speaker":"A"},{"text":"if","start":653570,"end":653850,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":653850,"end":654130,"confidence":1,"speaker":"A"},{"text":"have","start":654450,"end":654850,"confidence":0.99902344,"speaker":"A"},{"text":"built","start":655330,"end":655690,"confidence":0.99934894,"speaker":"A"},{"text":"an","start":655690,"end":655850,"confidence":0.99560547,"speaker":"A"},{"text":"app,","start":655850,"end":656130,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":656290,"end":656570,"confidence":1,"speaker":"A"},{"text":"could","start":656570,"end":656730,"confidence":0.6508789,"speaker":"A"},{"text":"just","start":656730,"end":656930,"confidence":0.99902344,"speaker":"A"},{"text":"add","start":656930,"end":657250,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":657410,"end":658290,"confidence":0.89294,"speaker":"A"},{"text":"right","start":658290,"end":658610,"confidence":0.99853516,"speaker":"A"},{"text":"here","start":658610,"end":658930,"confidence":0.9995117,"speaker":"A"},{"text":"within","start":659570,"end":659970,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":661330,"end":661730,"confidence":0.9970703,"speaker":"A"},{"text":"Xcode","start":662209,"end":662770,"confidence":0.91137695,"speaker":"A"},{"text":"project","start":662770,"end":663090,"confidence":1,"speaker":"A"},{"text":"and","start":663490,"end":663890,"confidence":0.9975586,"speaker":"A"},{"text":"use","start":665330,"end":665690,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":665690,"end":665970,"confidence":0.9995117,"speaker":"A"},{"text":"regular","start":665970,"end":666370,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":666370,"end":666970,"confidence":0.9975586,"speaker":"A"},{"text":"API","start":666970,"end":667490,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":667890,"end":668170,"confidence":0.5913086,"speaker":"A"},{"text":"Swift","start":668170,"end":668570,"confidence":0.9951172,"speaker":"A"},{"text":"to","start":668570,"end":668810,"confidence":0.99902344,"speaker":"A"},{"text":"go","start":668810,"end":668970,"confidence":0.9975586,"speaker":"A"},{"text":"ahead","start":668970,"end":669250,"confidence":0.9765625,"speaker":"A"},{"text":"and","start":669250,"end":669530,"confidence":0.99902344,"speaker":"A"},{"text":"start","start":669530,"end":669730,"confidence":1,"speaker":"A"},{"text":"using","start":669730,"end":669930,"confidence":1,"speaker":"A"},{"text":"it","start":669930,"end":670130,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":670130,"end":670330,"confidence":0.99902344,"speaker":"A"},{"text":"your","start":670330,"end":670530,"confidence":1,"speaker":"A"},{"text":"app.","start":670530,"end":670850,"confidence":0.9975586,"speaker":"A"}]},{"text":"Here is what it looks like to create a new record type. You can do all this through the CloudKit dashboard.","start":673390,"end":680190,"confidence":0.9946289,"words":[{"text":"Here","start":673390,"end":673630,"confidence":0.9946289,"speaker":"A"},{"text":"is","start":673630,"end":674030,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":674030,"end":674430,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":674430,"end":674750,"confidence":0.9980469,"speaker":"A"},{"text":"looks","start":674750,"end":675110,"confidence":1,"speaker":"A"},{"text":"like","start":675110,"end":675390,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":675390,"end":675750,"confidence":0.99902344,"speaker":"A"},{"text":"create","start":675750,"end":675990,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":675990,"end":676110,"confidence":0.9868164,"speaker":"A"},{"text":"new","start":676110,"end":676270,"confidence":0.99853516,"speaker":"A"},{"text":"record","start":676270,"end":676590,"confidence":0.9995117,"speaker":"A"},{"text":"type.","start":676590,"end":676990,"confidence":0.99194336,"speaker":"A"},{"text":"You","start":676990,"end":677150,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":677150,"end":677270,"confidence":1,"speaker":"A"},{"text":"do","start":677270,"end":677430,"confidence":1,"speaker":"A"},{"text":"all","start":677430,"end":677590,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":677590,"end":677870,"confidence":0.99853516,"speaker":"A"},{"text":"through","start":677870,"end":678270,"confidence":1,"speaker":"A"},{"text":"the","start":678430,"end":678790,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":678790,"end":679510,"confidence":0.9987793,"speaker":"A"},{"text":"dashboard.","start":679510,"end":680190,"confidence":0.99938965,"speaker":"A"}]},{"text":"In CloudKit you could also do this using a schema file too. And you can export and import your schema that way. And it's not a SQL based database, it's much more, no sequel ish or an abstract layer above it. But essentially you can create records kind of like a table but not quite in your records. You can create a struct for it.","start":684190,"end":712680,"confidence":0.7402344,"words":[{"text":"In","start":684190,"end":684470,"confidence":0.7402344,"speaker":"A"},{"text":"CloudKit","start":684470,"end":685150,"confidence":0.9477539,"speaker":"A"},{"text":"you","start":685390,"end":685670,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":685670,"end":685830,"confidence":0.8930664,"speaker":"A"},{"text":"also","start":685830,"end":686030,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":686030,"end":686230,"confidence":1,"speaker":"A"},{"text":"this","start":686230,"end":686470,"confidence":1,"speaker":"A"},{"text":"using","start":686470,"end":686830,"confidence":1,"speaker":"A"},{"text":"a","start":687150,"end":687430,"confidence":0.94921875,"speaker":"A"},{"text":"schema","start":687430,"end":687910,"confidence":0.9895833,"speaker":"A"},{"text":"file","start":687910,"end":688270,"confidence":0.8520508,"speaker":"A"},{"text":"too.","start":688670,"end":689070,"confidence":0.8598633,"speaker":"A"},{"text":"And","start":689390,"end":689670,"confidence":0.99316406,"speaker":"A"},{"text":"you","start":689670,"end":689830,"confidence":0.98583984,"speaker":"A"},{"text":"can","start":689830,"end":689990,"confidence":0.6220703,"speaker":"A"},{"text":"export","start":689990,"end":690310,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":690310,"end":690470,"confidence":0.9692383,"speaker":"A"},{"text":"import","start":690470,"end":690750,"confidence":0.9970703,"speaker":"A"},{"text":"your","start":690830,"end":691150,"confidence":0.99902344,"speaker":"A"},{"text":"schema","start":691150,"end":691710,"confidence":0.92041016,"speaker":"A"},{"text":"that","start":691710,"end":692030,"confidence":0.99658203,"speaker":"A"},{"text":"way.","start":692030,"end":692350,"confidence":0.9975586,"speaker":"A"},{"text":"And","start":693230,"end":693630,"confidence":0.98046875,"speaker":"A"},{"text":"it's","start":693630,"end":694070,"confidence":0.9996745,"speaker":"A"},{"text":"not","start":694070,"end":694350,"confidence":0.9980469,"speaker":"A"},{"text":"a","start":694590,"end":694870,"confidence":0.9321289,"speaker":"A"},{"text":"SQL","start":694870,"end":695190,"confidence":0.9423828,"speaker":"A"},{"text":"based","start":695190,"end":695430,"confidence":0.99902344,"speaker":"A"},{"text":"database,","start":695430,"end":696030,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":696030,"end":696270,"confidence":0.97802734,"speaker":"A"},{"text":"much","start":696270,"end":696470,"confidence":0.9980469,"speaker":"A"},{"text":"more,","start":696470,"end":696830,"confidence":0.9892578,"speaker":"A"},{"text":"no","start":697310,"end":697670,"confidence":0.9902344,"speaker":"A"},{"text":"sequel","start":697670,"end":698110,"confidence":0.8517253,"speaker":"A"},{"text":"ish","start":698110,"end":698430,"confidence":0.9033203,"speaker":"A"},{"text":"or","start":698430,"end":698630,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":698630,"end":698830,"confidence":0.9770508,"speaker":"A"},{"text":"abstract","start":698830,"end":699350,"confidence":0.9822591,"speaker":"A"},{"text":"layer","start":699350,"end":699910,"confidence":0.99886066,"speaker":"A"},{"text":"above","start":699910,"end":700230,"confidence":0.98461914,"speaker":"A"},{"text":"it.","start":700230,"end":700510,"confidence":0.99609375,"speaker":"A"},{"text":"But","start":701400,"end":701560,"confidence":0.99658203,"speaker":"A"},{"text":"essentially","start":701560,"end":702240,"confidence":0.97021484,"speaker":"A"},{"text":"you","start":702240,"end":702600,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":702680,"end":703080,"confidence":0.9995117,"speaker":"A"},{"text":"create","start":703080,"end":703440,"confidence":0.9970703,"speaker":"A"},{"text":"records","start":703440,"end":704120,"confidence":0.99658203,"speaker":"A"},{"text":"kind","start":704520,"end":704800,"confidence":0.99658203,"speaker":"A"},{"text":"of","start":704800,"end":704920,"confidence":0.9970703,"speaker":"A"},{"text":"like","start":704920,"end":705040,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":705040,"end":705200,"confidence":0.9995117,"speaker":"A"},{"text":"table","start":705200,"end":705480,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":705480,"end":705680,"confidence":0.99902344,"speaker":"A"},{"text":"not","start":705680,"end":705880,"confidence":0.99853516,"speaker":"A"},{"text":"quite","start":705880,"end":706280,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":707000,"end":707280,"confidence":0.98339844,"speaker":"A"},{"text":"your","start":707280,"end":707520,"confidence":0.9970703,"speaker":"A"},{"text":"records.","start":707520,"end":708200,"confidence":0.9963379,"speaker":"A"},{"text":"You","start":709400,"end":709680,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":709680,"end":709960,"confidence":0.9995117,"speaker":"A"},{"text":"create","start":710360,"end":710760,"confidence":0.9824219,"speaker":"A"},{"text":"a","start":711400,"end":711760,"confidence":0.9980469,"speaker":"A"},{"text":"struct","start":711760,"end":712240,"confidence":0.83862305,"speaker":"A"},{"text":"for","start":712240,"end":712480,"confidence":0.99902344,"speaker":"A"},{"text":"it.","start":712480,"end":712680,"confidence":0.9980469,"speaker":"A"}]},{"text":"You can just use CloudKit directly to go ahead and then you can then plug it into your app and do fun stuff like this. We can do things like queries and basic database stuff. There's a lot of advantages to it. For one, if you're doing Apple only, then it definitely makes sense to look into, at least look into CloudKit.","start":712680,"end":738080,"confidence":0.9995117,"words":[{"text":"You","start":712680,"end":712880,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":712880,"end":713040,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":713040,"end":713240,"confidence":1,"speaker":"A"},{"text":"use","start":713240,"end":713560,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":713960,"end":714600,"confidence":0.982666,"speaker":"A"},{"text":"directly","start":714600,"end":715120,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":715120,"end":715360,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":715360,"end":715520,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":715520,"end":715800,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":716440,"end":716760,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":716760,"end":717039,"confidence":0.99072266,"speaker":"A"},{"text":"you","start":717039,"end":717280,"confidence":0.98535156,"speaker":"A"},{"text":"can","start":717280,"end":717480,"confidence":0.88964844,"speaker":"A"},{"text":"then","start":717480,"end":717760,"confidence":0.78759766,"speaker":"A"},{"text":"plug","start":717760,"end":718080,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":718080,"end":718240,"confidence":0.99902344,"speaker":"A"},{"text":"into","start":718240,"end":718440,"confidence":0.99902344,"speaker":"A"},{"text":"your","start":718440,"end":718680,"confidence":0.9995117,"speaker":"A"},{"text":"app","start":718680,"end":718920,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":718920,"end":719240,"confidence":0.9628906,"speaker":"A"},{"text":"do","start":719240,"end":719520,"confidence":0.9995117,"speaker":"A"},{"text":"fun","start":719520,"end":719760,"confidence":0.99853516,"speaker":"A"},{"text":"stuff","start":719760,"end":720040,"confidence":1,"speaker":"A"},{"text":"like","start":720040,"end":720200,"confidence":0.9995117,"speaker":"A"},{"text":"this.","start":720200,"end":720520,"confidence":0.9946289,"speaker":"A"},{"text":"We","start":721560,"end":721880,"confidence":0.44580078,"speaker":"A"},{"text":"can","start":721880,"end":722080,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":722080,"end":722240,"confidence":1,"speaker":"A"},{"text":"things","start":722240,"end":722440,"confidence":1,"speaker":"A"},{"text":"like","start":722440,"end":722760,"confidence":0.9995117,"speaker":"A"},{"text":"queries","start":722840,"end":723520,"confidence":0.9477539,"speaker":"A"},{"text":"and","start":723520,"end":723880,"confidence":0.8354492,"speaker":"A"},{"text":"basic","start":724840,"end":725280,"confidence":0.99975586,"speaker":"A"},{"text":"database","start":725280,"end":725800,"confidence":0.99869794,"speaker":"A"},{"text":"stuff.","start":725800,"end":726200,"confidence":0.9996745,"speaker":"A"},{"text":"There's","start":726200,"end":726640,"confidence":0.99153644,"speaker":"A"},{"text":"a","start":726640,"end":726760,"confidence":0.99902344,"speaker":"A"},{"text":"lot","start":726760,"end":726840,"confidence":1,"speaker":"A"},{"text":"of","start":726840,"end":726960,"confidence":0.99902344,"speaker":"A"},{"text":"advantages","start":726960,"end":727520,"confidence":0.9991862,"speaker":"A"},{"text":"to","start":727520,"end":727760,"confidence":0.99853516,"speaker":"A"},{"text":"it.","start":727760,"end":728040,"confidence":0.99658203,"speaker":"A"},{"text":"For","start":729280,"end":729440,"confidence":0.9794922,"speaker":"A"},{"text":"one,","start":729440,"end":729760,"confidence":0.9667969,"speaker":"A"},{"text":"if","start":730080,"end":730400,"confidence":0.9995117,"speaker":"A"},{"text":"you're","start":730400,"end":730880,"confidence":0.95996094,"speaker":"A"},{"text":"doing","start":730960,"end":731360,"confidence":0.99902344,"speaker":"A"},{"text":"Apple","start":731840,"end":732320,"confidence":1,"speaker":"A"},{"text":"only,","start":732320,"end":732640,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":733600,"end":734000,"confidence":0.99658203,"speaker":"A"},{"text":"it","start":734000,"end":734280,"confidence":0.9995117,"speaker":"A"},{"text":"definitely","start":734280,"end":734680,"confidence":0.99938965,"speaker":"A"},{"text":"makes","start":734680,"end":734880,"confidence":0.9980469,"speaker":"A"},{"text":"sense","start":734880,"end":735280,"confidence":0.99975586,"speaker":"A"},{"text":"to","start":735520,"end":735840,"confidence":0.99853516,"speaker":"A"},{"text":"look","start":735840,"end":736120,"confidence":0.98046875,"speaker":"A"},{"text":"into,","start":736120,"end":736440,"confidence":0.53515625,"speaker":"A"},{"text":"at","start":736440,"end":736640,"confidence":0.9995117,"speaker":"A"},{"text":"least","start":736640,"end":736800,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":736800,"end":737040,"confidence":0.99902344,"speaker":"A"},{"text":"into","start":737040,"end":737320,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit.","start":737320,"end":738080,"confidence":0.9995117,"speaker":"A"}]},{"text":"If you're just going to deploy to Apple Devices. If you don't mind the, the fact that it's not a regular SQL database, that's something too to think about. If you like need a SQL database, this might not be what you want. And then if you don't mind working with a lot of the abstraction layers that CloudKit provides, then this might be good for you to get started or especially if you don't have any database experience. So as far as like server choices, I would say CloudKit might not be your first choice, but it certainly is a decent choice if you're going the Apple only route.","start":742320,"end":784450,"confidence":0.9980469,"words":[{"text":"If","start":742320,"end":742600,"confidence":0.9980469,"speaker":"A"},{"text":"you're","start":742600,"end":742800,"confidence":0.9996745,"speaker":"A"},{"text":"just","start":742800,"end":742920,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":742920,"end":743040,"confidence":0.92333984,"speaker":"A"},{"text":"to","start":743040,"end":743120,"confidence":0.99902344,"speaker":"A"},{"text":"deploy","start":743120,"end":743480,"confidence":1,"speaker":"A"},{"text":"to","start":743480,"end":743840,"confidence":0.99316406,"speaker":"A"},{"text":"Apple","start":744480,"end":744960,"confidence":0.99975586,"speaker":"A"},{"text":"Devices.","start":744960,"end":745440,"confidence":1,"speaker":"A"},{"text":"If","start":746080,"end":746440,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":746440,"end":746800,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":747120,"end":747560,"confidence":0.9637044,"speaker":"A"},{"text":"mind","start":747560,"end":747920,"confidence":0.9995117,"speaker":"A"},{"text":"the,","start":748320,"end":748720,"confidence":0.9042969,"speaker":"A"},{"text":"the","start":749920,"end":750200,"confidence":0.9995117,"speaker":"A"},{"text":"fact","start":750200,"end":750360,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":750360,"end":750520,"confidence":1,"speaker":"A"},{"text":"it's","start":750520,"end":750720,"confidence":0.9996745,"speaker":"A"},{"text":"not","start":750720,"end":750920,"confidence":0.84814453,"speaker":"A"},{"text":"a","start":750920,"end":751160,"confidence":0.5908203,"speaker":"A"},{"text":"regular","start":751160,"end":751560,"confidence":0.9992676,"speaker":"A"},{"text":"SQL","start":751560,"end":751960,"confidence":0.98860675,"speaker":"A"},{"text":"database,","start":751960,"end":752640,"confidence":0.9998372,"speaker":"A"},{"text":"that's","start":754050,"end":754210,"confidence":0.9980469,"speaker":"A"},{"text":"something","start":754210,"end":754410,"confidence":0.9995117,"speaker":"A"},{"text":"too","start":754410,"end":754650,"confidence":0.68408203,"speaker":"A"},{"text":"to","start":754650,"end":754810,"confidence":0.99853516,"speaker":"A"},{"text":"think","start":754810,"end":754930,"confidence":1,"speaker":"A"},{"text":"about.","start":754930,"end":755090,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":755090,"end":755290,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":755290,"end":755450,"confidence":1,"speaker":"A"},{"text":"like","start":755450,"end":755610,"confidence":0.92333984,"speaker":"A"},{"text":"need","start":755610,"end":755770,"confidence":0.9848633,"speaker":"A"},{"text":"a","start":755770,"end":755890,"confidence":0.9926758,"speaker":"A"},{"text":"SQL","start":755890,"end":756210,"confidence":0.96533203,"speaker":"A"},{"text":"database,","start":756210,"end":756650,"confidence":0.98063153,"speaker":"A"},{"text":"this","start":756650,"end":756850,"confidence":0.97998047,"speaker":"A"},{"text":"might","start":756850,"end":757050,"confidence":1,"speaker":"A"},{"text":"not","start":757050,"end":757210,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":757210,"end":757490,"confidence":1,"speaker":"A"},{"text":"what","start":757730,"end":758050,"confidence":0.9819336,"speaker":"A"},{"text":"you","start":758050,"end":758370,"confidence":0.9995117,"speaker":"A"},{"text":"want.","start":758370,"end":758770,"confidence":0.9926758,"speaker":"A"},{"text":"And","start":759410,"end":759690,"confidence":0.95654297,"speaker":"A"},{"text":"then","start":759690,"end":759890,"confidence":0.9819336,"speaker":"A"},{"text":"if","start":759890,"end":760050,"confidence":1,"speaker":"A"},{"text":"you","start":760050,"end":760170,"confidence":1,"speaker":"A"},{"text":"don't","start":760170,"end":760370,"confidence":1,"speaker":"A"},{"text":"mind","start":760370,"end":760530,"confidence":1,"speaker":"A"},{"text":"working","start":760530,"end":760770,"confidence":1,"speaker":"A"},{"text":"with","start":760770,"end":761010,"confidence":0.9848633,"speaker":"A"},{"text":"a","start":761010,"end":761170,"confidence":0.99902344,"speaker":"A"},{"text":"lot","start":761170,"end":761290,"confidence":1,"speaker":"A"},{"text":"of","start":761290,"end":761410,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":761410,"end":761530,"confidence":0.9995117,"speaker":"A"},{"text":"abstraction","start":761530,"end":762130,"confidence":0.9991455,"speaker":"A"},{"text":"layers","start":762130,"end":762610,"confidence":0.99934894,"speaker":"A"},{"text":"that","start":763010,"end":763330,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":763330,"end":763970,"confidence":0.99902344,"speaker":"A"},{"text":"provides,","start":763970,"end":764610,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":766930,"end":767330,"confidence":0.99658203,"speaker":"A"},{"text":"this","start":767650,"end":767970,"confidence":0.9995117,"speaker":"A"},{"text":"might","start":767970,"end":768170,"confidence":0.99609375,"speaker":"A"},{"text":"be","start":768170,"end":768370,"confidence":1,"speaker":"A"},{"text":"good","start":768370,"end":768530,"confidence":1,"speaker":"A"},{"text":"for","start":768530,"end":768650,"confidence":0.87402344,"speaker":"A"},{"text":"you","start":768650,"end":768850,"confidence":1,"speaker":"A"},{"text":"to","start":768850,"end":769050,"confidence":1,"speaker":"A"},{"text":"get","start":769050,"end":769210,"confidence":1,"speaker":"A"},{"text":"started","start":769210,"end":769490,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":770050,"end":770410,"confidence":0.99658203,"speaker":"A"},{"text":"especially","start":770410,"end":770730,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":770730,"end":770930,"confidence":1,"speaker":"A"},{"text":"you","start":770930,"end":771050,"confidence":1,"speaker":"A"},{"text":"don't","start":771050,"end":771250,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":771250,"end":771370,"confidence":1,"speaker":"A"},{"text":"any","start":771370,"end":771570,"confidence":0.9995117,"speaker":"A"},{"text":"database","start":771570,"end":772130,"confidence":0.9998372,"speaker":"A"},{"text":"experience.","start":772130,"end":772450,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":774130,"end":774410,"confidence":0.99316406,"speaker":"A"},{"text":"as","start":774410,"end":774570,"confidence":0.9995117,"speaker":"A"},{"text":"far","start":774570,"end":774730,"confidence":1,"speaker":"A"},{"text":"as","start":774730,"end":774930,"confidence":1,"speaker":"A"},{"text":"like","start":774930,"end":775250,"confidence":0.9770508,"speaker":"A"},{"text":"server","start":775570,"end":776090,"confidence":0.99975586,"speaker":"A"},{"text":"choices,","start":776090,"end":776650,"confidence":0.98291016,"speaker":"A"},{"text":"I","start":776650,"end":776850,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":776850,"end":777010,"confidence":1,"speaker":"A"},{"text":"say","start":777010,"end":777290,"confidence":1,"speaker":"A"},{"text":"CloudKit","start":777290,"end":777970,"confidence":0.9926758,"speaker":"A"},{"text":"might","start":777970,"end":778170,"confidence":0.99365234,"speaker":"A"},{"text":"not","start":778170,"end":778330,"confidence":0.57714844,"speaker":"A"},{"text":"be","start":778330,"end":778490,"confidence":1,"speaker":"A"},{"text":"your","start":778490,"end":778690,"confidence":1,"speaker":"A"},{"text":"first","start":778690,"end":778930,"confidence":0.9995117,"speaker":"A"},{"text":"choice,","start":778930,"end":779330,"confidence":0.99975586,"speaker":"A"},{"text":"but","start":779970,"end":780090,"confidence":0.9970703,"speaker":"A"},{"text":"it","start":780090,"end":780250,"confidence":0.99902344,"speaker":"A"},{"text":"certainly","start":780250,"end":780610,"confidence":1,"speaker":"A"},{"text":"is","start":780610,"end":780930,"confidence":1,"speaker":"A"},{"text":"a","start":780930,"end":781210,"confidence":0.9995117,"speaker":"A"},{"text":"decent","start":781210,"end":781570,"confidence":1,"speaker":"A"},{"text":"choice","start":781570,"end":781970,"confidence":0.99975586,"speaker":"A"},{"text":"if","start":782290,"end":782610,"confidence":0.6225586,"speaker":"A"},{"text":"you're","start":782610,"end":782890,"confidence":0.9943034,"speaker":"A"},{"text":"going","start":782890,"end":783090,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":783090,"end":783290,"confidence":0.9145508,"speaker":"A"},{"text":"Apple","start":783290,"end":783650,"confidence":0.9995117,"speaker":"A"},{"text":"only","start":783650,"end":783970,"confidence":0.9995117,"speaker":"A"},{"text":"route.","start":783970,"end":784450,"confidence":0.9938965,"speaker":"A"}]},{"text":"But then the question comes in, why would you want Cloud server side CloudKit? Why would you want to do anything with CloudKit on the server? So here's, here's the first case. Well, this is how you can go ahead and do that is they provide actually a REST API for calls to CloudKit using the, if you go to the documentation, I'll provide a link to that CloudKit Web Services which provides a lot of the documentation for what we'll be talking about today. A lot of this is abstracted out in the JavaScript library.","start":789970,"end":823790,"confidence":0.99658203,"words":[{"text":"But","start":789970,"end":790250,"confidence":0.99658203,"speaker":"A"},{"text":"then","start":790250,"end":790410,"confidence":1,"speaker":"A"},{"text":"the","start":790410,"end":790530,"confidence":1,"speaker":"A"},{"text":"question","start":790530,"end":790730,"confidence":1,"speaker":"A"},{"text":"comes","start":790730,"end":791010,"confidence":0.9951172,"speaker":"A"},{"text":"in,","start":791010,"end":791250,"confidence":0.97216797,"speaker":"A"},{"text":"why","start":791250,"end":791450,"confidence":1,"speaker":"A"},{"text":"would","start":791450,"end":791610,"confidence":1,"speaker":"A"},{"text":"you","start":791610,"end":791770,"confidence":1,"speaker":"A"},{"text":"want","start":791770,"end":792010,"confidence":0.99902344,"speaker":"A"},{"text":"Cloud","start":792010,"end":792450,"confidence":0.954834,"speaker":"A"},{"text":"server","start":792450,"end":792850,"confidence":0.98461914,"speaker":"A"},{"text":"side","start":792850,"end":793050,"confidence":0.55859375,"speaker":"A"},{"text":"CloudKit?","start":793050,"end":793730,"confidence":0.98095703,"speaker":"A"},{"text":"Why","start":793890,"end":794170,"confidence":1,"speaker":"A"},{"text":"would","start":794170,"end":794330,"confidence":1,"speaker":"A"},{"text":"you","start":794330,"end":794490,"confidence":1,"speaker":"A"},{"text":"want","start":794490,"end":794610,"confidence":0.9941406,"speaker":"A"},{"text":"to","start":794610,"end":794690,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":794690,"end":794810,"confidence":1,"speaker":"A"},{"text":"anything","start":794810,"end":795090,"confidence":1,"speaker":"A"},{"text":"with","start":795090,"end":795250,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":795250,"end":795810,"confidence":0.9885254,"speaker":"A"},{"text":"on","start":795810,"end":796009,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":796009,"end":796170,"confidence":0.9995117,"speaker":"A"},{"text":"server?","start":796170,"end":796610,"confidence":1,"speaker":"A"},{"text":"So","start":797970,"end":798250,"confidence":0.99316406,"speaker":"A"},{"text":"here's,","start":798250,"end":798610,"confidence":0.9793294,"speaker":"A"},{"text":"here's","start":798610,"end":799090,"confidence":0.9996745,"speaker":"A"},{"text":"the","start":799250,"end":799530,"confidence":0.9995117,"speaker":"A"},{"text":"first","start":799530,"end":799810,"confidence":0.9995117,"speaker":"A"},{"text":"case.","start":799890,"end":800290,"confidence":0.9995117,"speaker":"A"},{"text":"Well,","start":800690,"end":801090,"confidence":0.96533203,"speaker":"A"},{"text":"this","start":801250,"end":801530,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":801530,"end":801690,"confidence":1,"speaker":"A"},{"text":"how","start":801690,"end":801890,"confidence":1,"speaker":"A"},{"text":"you","start":801890,"end":802090,"confidence":1,"speaker":"A"},{"text":"can","start":802090,"end":802290,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":802290,"end":802490,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":802490,"end":802650,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":802650,"end":802850,"confidence":0.97216797,"speaker":"A"},{"text":"do","start":802850,"end":803050,"confidence":1,"speaker":"A"},{"text":"that","start":803050,"end":803250,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":803250,"end":803570,"confidence":0.90234375,"speaker":"A"},{"text":"they","start":803970,"end":804330,"confidence":0.99902344,"speaker":"A"},{"text":"provide","start":804330,"end":804690,"confidence":1,"speaker":"A"},{"text":"actually","start":804690,"end":805050,"confidence":0.9980469,"speaker":"A"},{"text":"a","start":805050,"end":805290,"confidence":0.91259766,"speaker":"A"},{"text":"REST","start":805290,"end":805490,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":805490,"end":806090,"confidence":0.95166016,"speaker":"A"},{"text":"for","start":806090,"end":806450,"confidence":0.9946289,"speaker":"A"},{"text":"calls","start":806450,"end":806930,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":806930,"end":807170,"confidence":0.9970703,"speaker":"A"},{"text":"CloudKit","start":807170,"end":807880,"confidence":0.9848633,"speaker":"A"},{"text":"using","start":808910,"end":809150,"confidence":0.95654297,"speaker":"A"},{"text":"the,","start":809310,"end":809710,"confidence":0.98828125,"speaker":"A"},{"text":"if","start":809950,"end":810230,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":810230,"end":810350,"confidence":1,"speaker":"A"},{"text":"go","start":810350,"end":810430,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":810430,"end":810550,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":810550,"end":810670,"confidence":0.9995117,"speaker":"A"},{"text":"documentation,","start":810670,"end":811350,"confidence":0.99902344,"speaker":"A"},{"text":"I'll","start":811350,"end":811670,"confidence":0.99820966,"speaker":"A"},{"text":"provide","start":811670,"end":811910,"confidence":0.99658203,"speaker":"A"},{"text":"a","start":811910,"end":812110,"confidence":0.9067383,"speaker":"A"},{"text":"link","start":812110,"end":812350,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":812350,"end":812550,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":812550,"end":812830,"confidence":0.8276367,"speaker":"A"},{"text":"CloudKit","start":812910,"end":813590,"confidence":0.87280273,"speaker":"A"},{"text":"Web","start":813590,"end":813830,"confidence":0.99658203,"speaker":"A"},{"text":"Services","start":813830,"end":814110,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":815310,"end":815710,"confidence":0.99902344,"speaker":"A"},{"text":"provides","start":816510,"end":816990,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":816990,"end":817070,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":817070,"end":817190,"confidence":1,"speaker":"A"},{"text":"of","start":817190,"end":817310,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":817310,"end":817430,"confidence":0.9980469,"speaker":"A"},{"text":"documentation","start":817430,"end":818070,"confidence":0.9998047,"speaker":"A"},{"text":"for","start":818070,"end":818270,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":818270,"end":818390,"confidence":0.99902344,"speaker":"A"},{"text":"we'll","start":818390,"end":818630,"confidence":0.8699544,"speaker":"A"},{"text":"be","start":818630,"end":818790,"confidence":1,"speaker":"A"},{"text":"talking","start":818790,"end":819030,"confidence":0.97631836,"speaker":"A"},{"text":"about","start":819030,"end":819230,"confidence":0.9995117,"speaker":"A"},{"text":"today.","start":819230,"end":819550,"confidence":0.99902344,"speaker":"A"},{"text":"A","start":820910,"end":821150,"confidence":0.99658203,"speaker":"A"},{"text":"lot","start":821150,"end":821270,"confidence":1,"speaker":"A"},{"text":"of","start":821270,"end":821430,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":821430,"end":821590,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":821590,"end":821790,"confidence":0.99853516,"speaker":"A"},{"text":"abstracted","start":821790,"end":822390,"confidence":0.88964844,"speaker":"A"},{"text":"out","start":822390,"end":822550,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":822550,"end":822670,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":822670,"end":822750,"confidence":0.9995117,"speaker":"A"},{"text":"JavaScript","start":822750,"end":823350,"confidence":0.99698895,"speaker":"A"},{"text":"library.","start":823350,"end":823790,"confidence":0.9916992,"speaker":"A"}]},{"text":"So if you want to do stuff on a website, they provide a CloudKit JavaScript library for that. Sorry, just going into do not disturb mode.","start":823870,"end":839230,"confidence":0.9838867,"words":[{"text":"So","start":823870,"end":824109,"confidence":0.9838867,"speaker":"A"},{"text":"if","start":824109,"end":824230,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":824230,"end":824350,"confidence":1,"speaker":"A"},{"text":"want","start":824350,"end":824510,"confidence":0.95166016,"speaker":"A"},{"text":"to","start":824510,"end":824670,"confidence":0.9980469,"speaker":"A"},{"text":"do","start":824670,"end":824790,"confidence":0.9995117,"speaker":"A"},{"text":"stuff","start":824790,"end":824990,"confidence":1,"speaker":"A"},{"text":"on","start":824990,"end":825110,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":825110,"end":825270,"confidence":0.98828125,"speaker":"A"},{"text":"website,","start":825270,"end":825550,"confidence":0.99609375,"speaker":"A"},{"text":"they","start":826430,"end":826790,"confidence":0.9995117,"speaker":"A"},{"text":"provide","start":826790,"end":827150,"confidence":1,"speaker":"A"},{"text":"a","start":827230,"end":827630,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":827790,"end":828590,"confidence":0.99438477,"speaker":"A"},{"text":"JavaScript","start":828590,"end":829390,"confidence":0.9239909,"speaker":"A"},{"text":"library","start":830270,"end":830830,"confidence":0.9996745,"speaker":"A"},{"text":"for","start":830830,"end":831110,"confidence":0.99853516,"speaker":"A"},{"text":"that.","start":831110,"end":831470,"confidence":0.99609375,"speaker":"A"},{"text":"Sorry,","start":833150,"end":833710,"confidence":0.8925781,"speaker":"A"},{"text":"just","start":836190,"end":836310,"confidence":0.93847656,"speaker":"A"},{"text":"going","start":836310,"end":836510,"confidence":0.9814453,"speaker":"A"},{"text":"into","start":836510,"end":836790,"confidence":0.9121094,"speaker":"A"},{"text":"do","start":836790,"end":837030,"confidence":0.99560547,"speaker":"A"},{"text":"not","start":837030,"end":837230,"confidence":0.99902344,"speaker":"A"},{"text":"disturb","start":837230,"end":837870,"confidence":0.87369794,"speaker":"A"},{"text":"mode.","start":838670,"end":839230,"confidence":0.73999023,"speaker":"A"}]},{"text":"They even in that web references documentation they provide a composing web service request and all these instructions about how to go ahead and do that. So man, was it like half a decade ago that I built Heart Twitch and at the time I don't think there was anything, there was anything like sign in with Apple even. And like I really didn't want like to explain how harshwitch works is you have like a watch and it will send the heart rate to the server and then the server will then use a web socket to push it out to a web page. And then you would point OBS or some sort of streaming software to the URL or to the browser window and then that way you can stream your heart rate. That's how it works.","start":847950,"end":900860,"confidence":0.9404297,"words":[{"text":"They","start":847950,"end":848270,"confidence":0.9404297,"speaker":"A"},{"text":"even","start":848270,"end":848590,"confidence":0.7373047,"speaker":"A"},{"text":"in","start":848750,"end":849030,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":849030,"end":849270,"confidence":0.99902344,"speaker":"A"},{"text":"web","start":849270,"end":849710,"confidence":0.9995117,"speaker":"A"},{"text":"references","start":849790,"end":850429,"confidence":0.9367676,"speaker":"A"},{"text":"documentation","start":850430,"end":851070,"confidence":0.97734374,"speaker":"A"},{"text":"they","start":851070,"end":851270,"confidence":0.9980469,"speaker":"A"},{"text":"provide","start":851270,"end":851510,"confidence":1,"speaker":"A"},{"text":"a","start":851510,"end":851710,"confidence":0.8413086,"speaker":"A"},{"text":"composing","start":851710,"end":852150,"confidence":0.92008466,"speaker":"A"},{"text":"web","start":852150,"end":852390,"confidence":0.998291,"speaker":"A"},{"text":"service","start":852390,"end":852630,"confidence":0.99902344,"speaker":"A"},{"text":"request","start":852630,"end":853150,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":853470,"end":853750,"confidence":0.9970703,"speaker":"A"},{"text":"all","start":853750,"end":853910,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":853910,"end":854110,"confidence":0.99902344,"speaker":"A"},{"text":"instructions","start":854110,"end":854670,"confidence":0.9996745,"speaker":"A"},{"text":"about","start":854670,"end":854910,"confidence":1,"speaker":"A"},{"text":"how","start":854910,"end":855070,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":855070,"end":855190,"confidence":1,"speaker":"A"},{"text":"go","start":855190,"end":855310,"confidence":1,"speaker":"A"},{"text":"ahead","start":855310,"end":855470,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":855470,"end":855670,"confidence":1,"speaker":"A"},{"text":"do","start":855670,"end":855830,"confidence":1,"speaker":"A"},{"text":"that.","start":855830,"end":856110,"confidence":1,"speaker":"A"},{"text":"So","start":857470,"end":857870,"confidence":0.98876953,"speaker":"A"},{"text":"man,","start":858270,"end":858590,"confidence":0.9482422,"speaker":"A"},{"text":"was","start":858590,"end":858790,"confidence":0.99853516,"speaker":"A"},{"text":"it","start":858790,"end":858950,"confidence":0.9277344,"speaker":"A"},{"text":"like","start":858950,"end":859110,"confidence":0.9941406,"speaker":"A"},{"text":"half","start":859110,"end":859310,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":859310,"end":859470,"confidence":0.99902344,"speaker":"A"},{"text":"decade","start":859470,"end":859790,"confidence":0.99975586,"speaker":"A"},{"text":"ago","start":859790,"end":860110,"confidence":1,"speaker":"A"},{"text":"that","start":860880,"end":861120,"confidence":0.97216797,"speaker":"A"},{"text":"I","start":861280,"end":861680,"confidence":0.97314453,"speaker":"A"},{"text":"built","start":862960,"end":863320,"confidence":0.99153644,"speaker":"A"},{"text":"Heart","start":863320,"end":863520,"confidence":0.8129883,"speaker":"A"},{"text":"Twitch","start":863520,"end":864000,"confidence":0.98999023,"speaker":"A"},{"text":"and","start":864480,"end":864880,"confidence":0.9814453,"speaker":"A"},{"text":"at","start":865360,"end":865640,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":865640,"end":865840,"confidence":0.99853516,"speaker":"A"},{"text":"time","start":865840,"end":866080,"confidence":1,"speaker":"A"},{"text":"I","start":866080,"end":866280,"confidence":1,"speaker":"A"},{"text":"don't","start":866280,"end":866520,"confidence":0.99934894,"speaker":"A"},{"text":"think","start":866520,"end":866720,"confidence":1,"speaker":"A"},{"text":"there","start":866720,"end":866960,"confidence":0.99365234,"speaker":"A"},{"text":"was","start":866960,"end":867280,"confidence":0.9995117,"speaker":"A"},{"text":"anything,","start":867440,"end":868080,"confidence":0.99975586,"speaker":"A"},{"text":"there","start":870080,"end":870360,"confidence":0.99658203,"speaker":"A"},{"text":"was","start":870360,"end":870560,"confidence":0.99902344,"speaker":"A"},{"text":"anything","start":870560,"end":870960,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":870960,"end":871200,"confidence":0.99902344,"speaker":"A"},{"text":"sign","start":871200,"end":871440,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":871440,"end":871640,"confidence":0.9819336,"speaker":"A"},{"text":"with","start":871640,"end":871800,"confidence":1,"speaker":"A"},{"text":"Apple","start":871800,"end":872160,"confidence":0.9995117,"speaker":"A"},{"text":"even.","start":872160,"end":872480,"confidence":0.9970703,"speaker":"A"},{"text":"And","start":872880,"end":873280,"confidence":0.97265625,"speaker":"A"},{"text":"like","start":873520,"end":873840,"confidence":0.9399414,"speaker":"A"},{"text":"I","start":873840,"end":874160,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":874160,"end":874560,"confidence":0.99902344,"speaker":"A"},{"text":"didn't","start":875120,"end":875640,"confidence":0.99348956,"speaker":"A"},{"text":"want","start":875640,"end":875920,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":876880,"end":877280,"confidence":0.9794922,"speaker":"A"},{"text":"to","start":878160,"end":878480,"confidence":0.98291016,"speaker":"A"},{"text":"explain","start":878480,"end":878760,"confidence":0.99853516,"speaker":"A"},{"text":"how","start":878760,"end":878920,"confidence":0.9995117,"speaker":"A"},{"text":"harshwitch","start":878920,"end":879520,"confidence":0.62939453,"speaker":"A"},{"text":"works","start":879520,"end":879800,"confidence":0.99975586,"speaker":"A"},{"text":"is","start":879800,"end":879960,"confidence":0.91064453,"speaker":"A"},{"text":"you","start":879960,"end":880120,"confidence":0.99853516,"speaker":"A"},{"text":"have","start":880120,"end":880320,"confidence":1,"speaker":"A"},{"text":"like","start":880320,"end":880520,"confidence":0.9902344,"speaker":"A"},{"text":"a","start":880520,"end":880680,"confidence":0.9995117,"speaker":"A"},{"text":"watch","start":880680,"end":880960,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":881360,"end":881720,"confidence":0.6225586,"speaker":"A"},{"text":"it","start":881720,"end":881960,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":881960,"end":882200,"confidence":0.9995117,"speaker":"A"},{"text":"send","start":882200,"end":882600,"confidence":0.9291992,"speaker":"A"},{"text":"the","start":882600,"end":882840,"confidence":0.9995117,"speaker":"A"},{"text":"heart","start":882840,"end":883040,"confidence":0.9995117,"speaker":"A"},{"text":"rate","start":883040,"end":883280,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":883280,"end":883480,"confidence":1,"speaker":"A"},{"text":"the","start":883480,"end":883640,"confidence":1,"speaker":"A"},{"text":"server","start":883640,"end":884160,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":885360,"end":885640,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":885640,"end":885920,"confidence":0.9926758,"speaker":"A"},{"text":"the","start":887020,"end":887180,"confidence":0.99658203,"speaker":"A"},{"text":"server","start":887180,"end":887580,"confidence":1,"speaker":"A"},{"text":"will","start":887580,"end":887780,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":887780,"end":888020,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":888020,"end":888260,"confidence":1,"speaker":"A"},{"text":"a","start":888260,"end":888420,"confidence":0.99853516,"speaker":"A"},{"text":"web","start":888420,"end":888660,"confidence":0.7871094,"speaker":"A"},{"text":"socket","start":888660,"end":889180,"confidence":0.9996745,"speaker":"A"},{"text":"to","start":889180,"end":889540,"confidence":0.9995117,"speaker":"A"},{"text":"push","start":889540,"end":889860,"confidence":1,"speaker":"A"},{"text":"it","start":889860,"end":890020,"confidence":0.99902344,"speaker":"A"},{"text":"out","start":890020,"end":890180,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":890180,"end":890340,"confidence":1,"speaker":"A"},{"text":"a","start":890340,"end":890500,"confidence":0.99853516,"speaker":"A"},{"text":"web","start":890500,"end":890740,"confidence":0.99975586,"speaker":"A"},{"text":"page.","start":890740,"end":891100,"confidence":0.84643555,"speaker":"A"},{"text":"And","start":892060,"end":892340,"confidence":0.97558594,"speaker":"A"},{"text":"then","start":892340,"end":892620,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":892620,"end":892900,"confidence":0.99902344,"speaker":"A"},{"text":"would","start":892900,"end":893180,"confidence":0.9838867,"speaker":"A"},{"text":"point","start":893500,"end":893900,"confidence":0.9926758,"speaker":"A"},{"text":"OBS","start":893980,"end":894380,"confidence":0.9897461,"speaker":"A"},{"text":"or","start":894540,"end":894780,"confidence":0.99072266,"speaker":"A"},{"text":"some","start":894780,"end":894900,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":894900,"end":895100,"confidence":0.9926758,"speaker":"A"},{"text":"of","start":895100,"end":895260,"confidence":0.53027344,"speaker":"A"},{"text":"streaming","start":895260,"end":895700,"confidence":0.91813153,"speaker":"A"},{"text":"software","start":895700,"end":896020,"confidence":0.9998779,"speaker":"A"},{"text":"to","start":896020,"end":896180,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":896180,"end":896340,"confidence":1,"speaker":"A"},{"text":"URL","start":896340,"end":896860,"confidence":0.99487305,"speaker":"A"},{"text":"or","start":896860,"end":897060,"confidence":0.9980469,"speaker":"A"},{"text":"to","start":897060,"end":897220,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":897220,"end":897340,"confidence":1,"speaker":"A"},{"text":"browser","start":897340,"end":897700,"confidence":0.9983724,"speaker":"A"},{"text":"window","start":897700,"end":898060,"confidence":1,"speaker":"A"},{"text":"and","start":898060,"end":898220,"confidence":0.99072266,"speaker":"A"},{"text":"then","start":898220,"end":898380,"confidence":0.8310547,"speaker":"A"},{"text":"that","start":898380,"end":898580,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":898580,"end":898740,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":898740,"end":898860,"confidence":1,"speaker":"A"},{"text":"can","start":898860,"end":898980,"confidence":0.9995117,"speaker":"A"},{"text":"stream","start":898980,"end":899260,"confidence":0.99609375,"speaker":"A"},{"text":"your","start":899260,"end":899460,"confidence":0.99853516,"speaker":"A"},{"text":"heart","start":899460,"end":899660,"confidence":0.9980469,"speaker":"A"},{"text":"rate.","start":899660,"end":899940,"confidence":0.9951172,"speaker":"A"},{"text":"That's","start":899940,"end":900220,"confidence":0.9996745,"speaker":"A"},{"text":"how","start":900220,"end":900300,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":900300,"end":900420,"confidence":0.99853516,"speaker":"A"},{"text":"works.","start":900420,"end":900860,"confidence":0.9946289,"speaker":"A"}]},{"text":"And what I really didn't want is a difficult way for a user to log in with a username and password on the watch because we all know typing on the watch is hell. So my, my thought was like, and I didn't have sign in with Apple, right? So my thought was why don't we use CloudKit? Because you're already signed in a CloudKit on the Watch with your, your id.","start":901500,"end":924080,"confidence":0.9711914,"words":[{"text":"And","start":901500,"end":901780,"confidence":0.9711914,"speaker":"A"},{"text":"what","start":901780,"end":901940,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":901940,"end":902100,"confidence":1,"speaker":"A"},{"text":"really","start":902100,"end":902339,"confidence":0.9995117,"speaker":"A"},{"text":"didn't","start":902339,"end":902659,"confidence":0.9980469,"speaker":"A"},{"text":"want","start":902659,"end":902900,"confidence":1,"speaker":"A"},{"text":"is","start":902900,"end":903180,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":903180,"end":903500,"confidence":0.9711914,"speaker":"A"},{"text":"difficult","start":903500,"end":903980,"confidence":0.9699707,"speaker":"A"},{"text":"way","start":903980,"end":904180,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":904180,"end":904380,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":904380,"end":904580,"confidence":0.8876953,"speaker":"A"},{"text":"user","start":904580,"end":904900,"confidence":1,"speaker":"A"},{"text":"to","start":904900,"end":905100,"confidence":0.9995117,"speaker":"A"},{"text":"log","start":905100,"end":905420,"confidence":1,"speaker":"A"},{"text":"in","start":905420,"end":905820,"confidence":0.9838867,"speaker":"A"},{"text":"with","start":906540,"end":906820,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":906820,"end":906980,"confidence":0.7949219,"speaker":"A"},{"text":"username","start":906980,"end":907500,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":907500,"end":907620,"confidence":0.99902344,"speaker":"A"},{"text":"password","start":907620,"end":908020,"confidence":0.90152997,"speaker":"A"},{"text":"on","start":908020,"end":908180,"confidence":0.6225586,"speaker":"A"},{"text":"the","start":908180,"end":908340,"confidence":0.9995117,"speaker":"A"},{"text":"watch","start":908340,"end":908620,"confidence":0.9995117,"speaker":"A"},{"text":"because","start":908620,"end":908900,"confidence":0.72558594,"speaker":"A"},{"text":"we","start":908900,"end":909020,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":909020,"end":909140,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":909140,"end":909300,"confidence":0.9980469,"speaker":"A"},{"text":"typing","start":909300,"end":909620,"confidence":0.8249512,"speaker":"A"},{"text":"on","start":909620,"end":909740,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":909740,"end":909820,"confidence":0.9951172,"speaker":"A"},{"text":"watch","start":909820,"end":910020,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":910020,"end":910380,"confidence":0.84472656,"speaker":"A"},{"text":"hell.","start":910780,"end":911260,"confidence":0.9157715,"speaker":"A"},{"text":"So","start":911900,"end":912300,"confidence":0.9770508,"speaker":"A"},{"text":"my,","start":912460,"end":912860,"confidence":0.70410156,"speaker":"A"},{"text":"my","start":912860,"end":913140,"confidence":0.9995117,"speaker":"A"},{"text":"thought","start":913140,"end":913340,"confidence":0.99902344,"speaker":"A"},{"text":"was","start":913340,"end":913620,"confidence":0.99853516,"speaker":"A"},{"text":"like,","start":913620,"end":913980,"confidence":0.9897461,"speaker":"A"},{"text":"and","start":914320,"end":914480,"confidence":0.6791992,"speaker":"A"},{"text":"I","start":914480,"end":914680,"confidence":1,"speaker":"A"},{"text":"didn't","start":914680,"end":914920,"confidence":0.9996745,"speaker":"A"},{"text":"have","start":914920,"end":915200,"confidence":0.9921875,"speaker":"A"},{"text":"sign","start":915280,"end":915600,"confidence":0.8886719,"speaker":"A"},{"text":"in","start":915600,"end":915800,"confidence":0.59814453,"speaker":"A"},{"text":"with","start":915800,"end":915960,"confidence":1,"speaker":"A"},{"text":"Apple,","start":915960,"end":916280,"confidence":1,"speaker":"A"},{"text":"right?","start":916280,"end":916560,"confidence":0.9970703,"speaker":"A"},{"text":"So","start":917440,"end":917720,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":917720,"end":917880,"confidence":0.99902344,"speaker":"A"},{"text":"thought","start":917880,"end":918080,"confidence":0.9995117,"speaker":"A"},{"text":"was","start":918080,"end":918320,"confidence":0.99902344,"speaker":"A"},{"text":"why","start":918320,"end":918520,"confidence":1,"speaker":"A"},{"text":"don't","start":918520,"end":918720,"confidence":0.9972331,"speaker":"A"},{"text":"we","start":918720,"end":918840,"confidence":1,"speaker":"A"},{"text":"use","start":918840,"end":919000,"confidence":1,"speaker":"A"},{"text":"CloudKit?","start":919000,"end":919680,"confidence":0.9992676,"speaker":"A"},{"text":"Because","start":919840,"end":920120,"confidence":0.98095703,"speaker":"A"},{"text":"you're","start":920120,"end":920320,"confidence":0.9998372,"speaker":"A"},{"text":"already","start":920320,"end":920520,"confidence":1,"speaker":"A"},{"text":"signed","start":920520,"end":920880,"confidence":0.9963379,"speaker":"A"},{"text":"in","start":920880,"end":921000,"confidence":0.71728516,"speaker":"A"},{"text":"a","start":921000,"end":921120,"confidence":0.61376953,"speaker":"A"},{"text":"CloudKit","start":921120,"end":921640,"confidence":0.99658203,"speaker":"A"},{"text":"on","start":921640,"end":921800,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":921800,"end":921960,"confidence":1,"speaker":"A"},{"text":"Watch","start":921960,"end":922240,"confidence":0.99853516,"speaker":"A"},{"text":"with","start":922800,"end":923120,"confidence":0.99853516,"speaker":"A"},{"text":"your,","start":923120,"end":923440,"confidence":0.9980469,"speaker":"A"},{"text":"your","start":923440,"end":923760,"confidence":0.9995117,"speaker":"A"},{"text":"id.","start":923760,"end":924080,"confidence":0.9995117,"speaker":"A"}]},{"text":"And what you do is you log in with a regular like email address and password in Heart Twitch on the website. And then there's a little, there's a site, there's a part of the site where you can sign into CloudKit and then from there you can, because, because of the CloudKit JavaScript library, you can then I can then pull the all the devices because when you first launch the app on the Watch, it adds your watch to the CloudKit database. And then I could pull that in and then add that to my postgres database. So then there is no need for authentication because I already have the CloudKit, the device added in my postgres database. So it's kind of like knows, oh yeah, this is Leo's watch, he doesn't need to authenticate.","start":926640,"end":975520,"confidence":0.99316406,"words":[{"text":"And","start":926640,"end":926920,"confidence":0.99316406,"speaker":"A"},{"text":"what","start":926920,"end":927080,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":927080,"end":927320,"confidence":1,"speaker":"A"},{"text":"do","start":927320,"end":927680,"confidence":1,"speaker":"A"},{"text":"is","start":928320,"end":928720,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":929440,"end":929720,"confidence":0.9995117,"speaker":"A"},{"text":"log","start":929720,"end":929920,"confidence":1,"speaker":"A"},{"text":"in","start":929920,"end":930159,"confidence":0.9975586,"speaker":"A"},{"text":"with","start":930159,"end":930359,"confidence":1,"speaker":"A"},{"text":"a","start":930359,"end":930480,"confidence":0.9794922,"speaker":"A"},{"text":"regular","start":930480,"end":930760,"confidence":1,"speaker":"A"},{"text":"like","start":930760,"end":930960,"confidence":0.9975586,"speaker":"A"},{"text":"email","start":930960,"end":931240,"confidence":1,"speaker":"A"},{"text":"address","start":931240,"end":931520,"confidence":1,"speaker":"A"},{"text":"and","start":931520,"end":931760,"confidence":0.6791992,"speaker":"A"},{"text":"password","start":931760,"end":932320,"confidence":0.88378906,"speaker":"A"},{"text":"in","start":933040,"end":933440,"confidence":0.7763672,"speaker":"A"},{"text":"Heart","start":933680,"end":934000,"confidence":0.66796875,"speaker":"A"},{"text":"Twitch","start":934000,"end":934400,"confidence":0.9975586,"speaker":"A"},{"text":"on","start":934400,"end":934560,"confidence":1,"speaker":"A"},{"text":"the","start":934560,"end":934680,"confidence":1,"speaker":"A"},{"text":"website.","start":934680,"end":934960,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":935840,"end":936120,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":936120,"end":936280,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":936280,"end":936520,"confidence":0.8927409,"speaker":"A"},{"text":"a","start":936520,"end":936640,"confidence":0.9995117,"speaker":"A"},{"text":"little,","start":936640,"end":936840,"confidence":1,"speaker":"A"},{"text":"there's","start":936840,"end":937200,"confidence":0.9996745,"speaker":"A"},{"text":"a","start":937200,"end":937360,"confidence":0.9995117,"speaker":"A"},{"text":"site,","start":937360,"end":937640,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":937640,"end":937960,"confidence":0.99886066,"speaker":"A"},{"text":"a","start":937960,"end":938160,"confidence":0.9995117,"speaker":"A"},{"text":"part","start":938160,"end":938360,"confidence":1,"speaker":"A"},{"text":"of","start":938360,"end":938480,"confidence":1,"speaker":"A"},{"text":"the","start":938480,"end":938560,"confidence":1,"speaker":"A"},{"text":"site","start":938560,"end":938720,"confidence":1,"speaker":"A"},{"text":"where","start":938720,"end":938920,"confidence":1,"speaker":"A"},{"text":"you","start":938920,"end":939040,"confidence":1,"speaker":"A"},{"text":"can","start":939040,"end":939280,"confidence":1,"speaker":"A"},{"text":"sign","start":939840,"end":940120,"confidence":1,"speaker":"A"},{"text":"into","start":940120,"end":940360,"confidence":0.8144531,"speaker":"A"},{"text":"CloudKit","start":940360,"end":941120,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":942180,"end":942300,"confidence":0.94628906,"speaker":"A"},{"text":"then","start":942300,"end":942500,"confidence":0.99902344,"speaker":"A"},{"text":"from","start":942500,"end":942740,"confidence":1,"speaker":"A"},{"text":"there","start":942740,"end":943060,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":944180,"end":944540,"confidence":0.9526367,"speaker":"A"},{"text":"can,","start":944540,"end":944900,"confidence":1,"speaker":"A"},{"text":"because,","start":945860,"end":946260,"confidence":0.8623047,"speaker":"A"},{"text":"because","start":946260,"end":946540,"confidence":0.99853516,"speaker":"A"},{"text":"of","start":946540,"end":946700,"confidence":0.9897461,"speaker":"A"},{"text":"the","start":946700,"end":946820,"confidence":0.9980469,"speaker":"A"},{"text":"CloudKit","start":946820,"end":947340,"confidence":0.99438477,"speaker":"A"},{"text":"JavaScript","start":947340,"end":947980,"confidence":0.9984538,"speaker":"A"},{"text":"library,","start":947980,"end":948380,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":948380,"end":948540,"confidence":0.95751953,"speaker":"A"},{"text":"can","start":948540,"end":948660,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":948660,"end":948820,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":948820,"end":948980,"confidence":0.9951172,"speaker":"A"},{"text":"can","start":948980,"end":949100,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":949100,"end":949300,"confidence":0.9951172,"speaker":"A"},{"text":"pull","start":949300,"end":949620,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":949620,"end":949940,"confidence":0.9140625,"speaker":"A"},{"text":"all","start":952260,"end":952580,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":952580,"end":952780,"confidence":0.99902344,"speaker":"A"},{"text":"devices","start":952780,"end":953220,"confidence":0.9992676,"speaker":"A"},{"text":"because","start":953220,"end":953540,"confidence":0.99902344,"speaker":"A"},{"text":"when","start":953540,"end":953740,"confidence":1,"speaker":"A"},{"text":"you","start":953740,"end":953900,"confidence":0.9995117,"speaker":"A"},{"text":"first","start":953900,"end":954100,"confidence":1,"speaker":"A"},{"text":"launch","start":954100,"end":954340,"confidence":1,"speaker":"A"},{"text":"the","start":954340,"end":954540,"confidence":0.9746094,"speaker":"A"},{"text":"app","start":954540,"end":954700,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":954700,"end":954820,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":954820,"end":954900,"confidence":0.9995117,"speaker":"A"},{"text":"Watch,","start":954900,"end":955100,"confidence":0.9897461,"speaker":"A"},{"text":"it","start":955100,"end":955340,"confidence":0.93408203,"speaker":"A"},{"text":"adds","start":955340,"end":955580,"confidence":0.9987793,"speaker":"A"},{"text":"your","start":955580,"end":955740,"confidence":0.9980469,"speaker":"A"},{"text":"watch","start":955740,"end":956020,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":956340,"end":956620,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":956620,"end":956740,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":956740,"end":957300,"confidence":0.99609375,"speaker":"A"},{"text":"database.","start":957300,"end":957940,"confidence":0.9998372,"speaker":"A"},{"text":"And","start":958260,"end":958540,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":958540,"end":958660,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":958660,"end":958780,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":958780,"end":958940,"confidence":0.66503906,"speaker":"A"},{"text":"pull","start":958940,"end":959140,"confidence":1,"speaker":"A"},{"text":"that","start":959140,"end":959300,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":959300,"end":959540,"confidence":0.9980469,"speaker":"A"},{"text":"and","start":959540,"end":959740,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":959740,"end":959900,"confidence":0.9970703,"speaker":"A"},{"text":"add","start":959900,"end":960060,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":960060,"end":960220,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":960220,"end":960380,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":960380,"end":960540,"confidence":0.9995117,"speaker":"A"},{"text":"postgres","start":960540,"end":961140,"confidence":0.98583984,"speaker":"A"},{"text":"database.","start":961140,"end":961700,"confidence":1,"speaker":"A"},{"text":"So","start":961700,"end":961980,"confidence":0.99658203,"speaker":"A"},{"text":"then","start":961980,"end":962260,"confidence":0.9970703,"speaker":"A"},{"text":"there","start":962260,"end":962540,"confidence":1,"speaker":"A"},{"text":"is","start":962540,"end":962740,"confidence":0.9995117,"speaker":"A"},{"text":"no","start":962740,"end":962940,"confidence":0.9995117,"speaker":"A"},{"text":"need","start":962940,"end":963140,"confidence":1,"speaker":"A"},{"text":"for","start":963140,"end":963380,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":963380,"end":964180,"confidence":0.9998779,"speaker":"A"},{"text":"because","start":964740,"end":965140,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":965220,"end":965500,"confidence":0.9980469,"speaker":"A"},{"text":"already","start":965500,"end":965700,"confidence":1,"speaker":"A"},{"text":"have","start":965700,"end":965900,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":965900,"end":966060,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit,","start":966060,"end":966740,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":967720,"end":967880,"confidence":0.9663086,"speaker":"A"},{"text":"device","start":967880,"end":968280,"confidence":0.9992676,"speaker":"A"},{"text":"added","start":968280,"end":968600,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":969000,"end":969280,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":969280,"end":969480,"confidence":0.9926758,"speaker":"A"},{"text":"postgres","start":969480,"end":970000,"confidence":0.89941406,"speaker":"A"},{"text":"database.","start":970000,"end":970400,"confidence":0.9998372,"speaker":"A"},{"text":"So","start":970400,"end":970520,"confidence":0.8930664,"speaker":"A"},{"text":"it's","start":970520,"end":970720,"confidence":0.87093097,"speaker":"A"},{"text":"kind","start":970720,"end":970840,"confidence":0.93603516,"speaker":"A"},{"text":"of","start":970840,"end":970960,"confidence":0.859375,"speaker":"A"},{"text":"like","start":970960,"end":971120,"confidence":0.9736328,"speaker":"A"},{"text":"knows,","start":971120,"end":971440,"confidence":0.94555664,"speaker":"A"},{"text":"oh","start":971440,"end":971680,"confidence":0.97143555,"speaker":"A"},{"text":"yeah,","start":971680,"end":972040,"confidence":0.9983724,"speaker":"A"},{"text":"this","start":972200,"end":972480,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":972480,"end":972720,"confidence":0.99902344,"speaker":"A"},{"text":"Leo's","start":972720,"end":973280,"confidence":0.9902344,"speaker":"A"},{"text":"watch,","start":973280,"end":973560,"confidence":0.99853516,"speaker":"A"},{"text":"he","start":974040,"end":974320,"confidence":0.99902344,"speaker":"A"},{"text":"doesn't","start":974320,"end":974520,"confidence":0.9996745,"speaker":"A"},{"text":"need","start":974520,"end":974640,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":974640,"end":974840,"confidence":0.9863281,"speaker":"A"},{"text":"authenticate.","start":974840,"end":975520,"confidence":0.9996338,"speaker":"A"}]},{"text":"And that way we can link devices to accounts without having to do any sort of login process. And so this was my use case for doing server side. Essentially CloudKit was I could call the CloudKit web server based on that person's web authentication token, which we'll get all into later. I then pull that information in. So.","start":975520,"end":1002450,"confidence":0.9116211,"words":[{"text":"And","start":975520,"end":975760,"confidence":0.9116211,"speaker":"A"},{"text":"that","start":975760,"end":975920,"confidence":0.99365234,"speaker":"A"},{"text":"way","start":975920,"end":976120,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":976120,"end":976320,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":976320,"end":976520,"confidence":0.9995117,"speaker":"A"},{"text":"link","start":976520,"end":976800,"confidence":0.99975586,"speaker":"A"},{"text":"devices","start":976800,"end":977240,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":977240,"end":977520,"confidence":0.9614258,"speaker":"A"},{"text":"accounts","start":977520,"end":978200,"confidence":0.9980469,"speaker":"A"},{"text":"without","start":978280,"end":978680,"confidence":0.9995117,"speaker":"A"},{"text":"having","start":978680,"end":978960,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":978960,"end":979120,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":979120,"end":979280,"confidence":0.9995117,"speaker":"A"},{"text":"any","start":979280,"end":979440,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":979440,"end":979640,"confidence":0.99625653,"speaker":"A"},{"text":"of","start":979640,"end":979760,"confidence":0.9951172,"speaker":"A"},{"text":"login","start":979760,"end":980200,"confidence":0.984375,"speaker":"A"},{"text":"process.","start":980200,"end":980520,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":981080,"end":981360,"confidence":0.9008789,"speaker":"A"},{"text":"so","start":981360,"end":981600,"confidence":0.59228516,"speaker":"A"},{"text":"this","start":981600,"end":981840,"confidence":0.9995117,"speaker":"A"},{"text":"was","start":981840,"end":982000,"confidence":0.9951172,"speaker":"A"},{"text":"my","start":982000,"end":982200,"confidence":0.99902344,"speaker":"A"},{"text":"use","start":982200,"end":982440,"confidence":0.9916992,"speaker":"A"},{"text":"case","start":982440,"end":982760,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":982919,"end":983320,"confidence":0.9995117,"speaker":"A"},{"text":"doing","start":983800,"end":984200,"confidence":0.99902344,"speaker":"A"},{"text":"server","start":985160,"end":985680,"confidence":0.71899414,"speaker":"A"},{"text":"side.","start":985680,"end":985960,"confidence":0.9086914,"speaker":"A"},{"text":"Essentially","start":986040,"end":986680,"confidence":0.9888916,"speaker":"A"},{"text":"CloudKit","start":987000,"end":987720,"confidence":0.87207,"speaker":"A"},{"text":"was","start":987720,"end":988000,"confidence":0.98583984,"speaker":"A"},{"text":"I","start":988000,"end":988240,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":988240,"end":988400,"confidence":0.99365234,"speaker":"A"},{"text":"call","start":988400,"end":988600,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":988600,"end":988800,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":988800,"end":989360,"confidence":0.9609375,"speaker":"A"},{"text":"web","start":989360,"end":989560,"confidence":0.9902344,"speaker":"A"},{"text":"server","start":989560,"end":990040,"confidence":0.99902344,"speaker":"A"},{"text":"based","start":993410,"end":993610,"confidence":0.98876953,"speaker":"A"},{"text":"on","start":993610,"end":993850,"confidence":1,"speaker":"A"},{"text":"that","start":993850,"end":994050,"confidence":0.9995117,"speaker":"A"},{"text":"person's","start":994050,"end":994690,"confidence":0.99690753,"speaker":"A"},{"text":"web","start":995570,"end":995970,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":995970,"end":996610,"confidence":0.9998779,"speaker":"A"},{"text":"token,","start":996610,"end":996970,"confidence":0.9998372,"speaker":"A"},{"text":"which","start":996970,"end":997130,"confidence":0.9995117,"speaker":"A"},{"text":"we'll","start":997130,"end":997330,"confidence":0.9316406,"speaker":"A"},{"text":"get","start":997330,"end":997490,"confidence":0.99902344,"speaker":"A"},{"text":"all","start":997490,"end":997730,"confidence":0.74365234,"speaker":"A"},{"text":"into","start":997730,"end":998010,"confidence":0.99072266,"speaker":"A"},{"text":"later.","start":998010,"end":998370,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":998530,"end":998850,"confidence":0.5698242,"speaker":"A"},{"text":"then","start":998850,"end":999050,"confidence":0.91748047,"speaker":"A"},{"text":"pull","start":999050,"end":999250,"confidence":0.99975586,"speaker":"A"},{"text":"that","start":999250,"end":999410,"confidence":0.9980469,"speaker":"A"},{"text":"information","start":999410,"end":999730,"confidence":0.9995117,"speaker":"A"},{"text":"in.","start":999970,"end":1000370,"confidence":0.9824219,"speaker":"A"},{"text":"So.","start":1002050,"end":1002450,"confidence":0.8515625,"speaker":"A"}]},{"text":"Cool.","start":1007250,"end":1007730,"confidence":0.9333496,"words":[{"text":"Cool.","start":1007250,"end":1007730,"confidence":0.9333496,"speaker":"A"}]},{"text":"Just checking if anybody's having issues. It doesn't look like it. So that's good to know. So that was the private database piece, but I actually think a much more useful case would be the public database because the idea would be is that you'd have some sort of app that would use central repository of data that it can pull information from. And I'm looking at both of these with Bushel and then an RSS reader I'm building called Celestra with Bushel.","start":1010770,"end":1045150,"confidence":0.99121094,"words":[{"text":"Just","start":1010770,"end":1011050,"confidence":0.99121094,"speaker":"A"},{"text":"checking","start":1011050,"end":1011370,"confidence":0.9980469,"speaker":"A"},{"text":"if","start":1011370,"end":1011530,"confidence":0.99853516,"speaker":"A"},{"text":"anybody's","start":1011530,"end":1012050,"confidence":0.94539386,"speaker":"A"},{"text":"having","start":1012050,"end":1012210,"confidence":0.9995117,"speaker":"A"},{"text":"issues.","start":1012210,"end":1012530,"confidence":0.99853516,"speaker":"A"},{"text":"It","start":1012530,"end":1012770,"confidence":0.5439453,"speaker":"A"},{"text":"doesn't","start":1012770,"end":1013050,"confidence":0.9983724,"speaker":"A"},{"text":"look","start":1013050,"end":1013210,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":1013210,"end":1013370,"confidence":0.99853516,"speaker":"A"},{"text":"it.","start":1013370,"end":1013650,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":1013650,"end":1014050,"confidence":0.8925781,"speaker":"A"},{"text":"that's","start":1014690,"end":1015050,"confidence":0.98014325,"speaker":"A"},{"text":"good","start":1015050,"end":1015210,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1015210,"end":1015370,"confidence":0.9980469,"speaker":"A"},{"text":"know.","start":1015370,"end":1015650,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":1017170,"end":1017410,"confidence":0.9707031,"speaker":"A"},{"text":"that","start":1017410,"end":1017530,"confidence":0.98779297,"speaker":"A"},{"text":"was","start":1017530,"end":1017690,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1017690,"end":1017850,"confidence":0.9995117,"speaker":"A"},{"text":"private","start":1017850,"end":1018090,"confidence":0.9995117,"speaker":"A"},{"text":"database","start":1018090,"end":1018690,"confidence":0.9998372,"speaker":"A"},{"text":"piece,","start":1018690,"end":1019090,"confidence":0.99576825,"speaker":"A"},{"text":"but","start":1019950,"end":1020070,"confidence":0.97558594,"speaker":"A"},{"text":"I","start":1020070,"end":1020230,"confidence":0.99853516,"speaker":"A"},{"text":"actually","start":1020230,"end":1020470,"confidence":0.9970703,"speaker":"A"},{"text":"think","start":1020470,"end":1020790,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1020790,"end":1021030,"confidence":0.9921875,"speaker":"A"},{"text":"much","start":1021030,"end":1021230,"confidence":0.9946289,"speaker":"A"},{"text":"more","start":1021230,"end":1021470,"confidence":1,"speaker":"A"},{"text":"useful","start":1021470,"end":1021910,"confidence":0.99975586,"speaker":"A"},{"text":"case","start":1021910,"end":1022270,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":1022670,"end":1022990,"confidence":1,"speaker":"A"},{"text":"be","start":1022990,"end":1023270,"confidence":1,"speaker":"A"},{"text":"the","start":1023270,"end":1023510,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1023510,"end":1023750,"confidence":0.9995117,"speaker":"A"},{"text":"database","start":1023750,"end":1024430,"confidence":0.99934894,"speaker":"A"},{"text":"because","start":1024990,"end":1025390,"confidence":0.9946289,"speaker":"A"},{"text":"the","start":1026830,"end":1027150,"confidence":0.99853516,"speaker":"A"},{"text":"idea","start":1027150,"end":1027550,"confidence":0.9758301,"speaker":"A"},{"text":"would","start":1027550,"end":1027750,"confidence":0.99658203,"speaker":"A"},{"text":"be","start":1027750,"end":1027950,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":1027950,"end":1028150,"confidence":0.93359375,"speaker":"A"},{"text":"that","start":1028150,"end":1028310,"confidence":0.99853516,"speaker":"A"},{"text":"you'd","start":1028310,"end":1028630,"confidence":0.96516925,"speaker":"A"},{"text":"have","start":1028630,"end":1028910,"confidence":1,"speaker":"A"},{"text":"some","start":1029710,"end":1029990,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":1029990,"end":1030230,"confidence":0.99609375,"speaker":"A"},{"text":"of","start":1030230,"end":1030390,"confidence":0.9975586,"speaker":"A"},{"text":"app","start":1030390,"end":1030670,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1030670,"end":1030950,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":1030950,"end":1031150,"confidence":0.9970703,"speaker":"A"},{"text":"use","start":1031150,"end":1031470,"confidence":0.99902344,"speaker":"A"},{"text":"central","start":1031550,"end":1031950,"confidence":0.9995117,"speaker":"A"},{"text":"repository","start":1031950,"end":1032790,"confidence":0.99694824,"speaker":"A"},{"text":"of","start":1032790,"end":1032990,"confidence":0.99853516,"speaker":"A"},{"text":"data","start":1032990,"end":1033310,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1035470,"end":1035790,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":1035790,"end":1035950,"confidence":0.63134766,"speaker":"A"},{"text":"can","start":1035950,"end":1036070,"confidence":0.9980469,"speaker":"A"},{"text":"pull","start":1036070,"end":1036390,"confidence":0.99975586,"speaker":"A"},{"text":"information","start":1036390,"end":1036750,"confidence":1,"speaker":"A"},{"text":"from.","start":1036990,"end":1037390,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":1037790,"end":1038110,"confidence":0.91259766,"speaker":"A"},{"text":"I'm","start":1038110,"end":1038390,"confidence":0.99104816,"speaker":"A"},{"text":"looking","start":1038390,"end":1038550,"confidence":0.9902344,"speaker":"A"},{"text":"at","start":1038550,"end":1038710,"confidence":0.99902344,"speaker":"A"},{"text":"both","start":1038710,"end":1038870,"confidence":1,"speaker":"A"},{"text":"of","start":1038870,"end":1039030,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":1039030,"end":1039310,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":1039310,"end":1039710,"confidence":0.99902344,"speaker":"A"},{"text":"Bushel","start":1039950,"end":1040590,"confidence":0.90722656,"speaker":"A"},{"text":"and","start":1040590,"end":1040790,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":1040790,"end":1040950,"confidence":0.9584961,"speaker":"A"},{"text":"an","start":1040950,"end":1041190,"confidence":0.98291016,"speaker":"A"},{"text":"RSS","start":1041190,"end":1041670,"confidence":0.9987793,"speaker":"A"},{"text":"reader","start":1041670,"end":1042070,"confidence":0.9975586,"speaker":"A"},{"text":"I'm","start":1042070,"end":1042270,"confidence":0.93929034,"speaker":"A"},{"text":"building","start":1042270,"end":1042430,"confidence":0.9995117,"speaker":"A"},{"text":"called","start":1042430,"end":1042630,"confidence":0.9584961,"speaker":"A"},{"text":"Celestra","start":1042630,"end":1043310,"confidence":0.9358724,"speaker":"A"},{"text":"with","start":1044190,"end":1044510,"confidence":0.98535156,"speaker":"A"},{"text":"Bushel.","start":1044510,"end":1045150,"confidence":0.9350586,"speaker":"A"}]},{"text":"The. The way it's built right now is I have this concept of hubs and you can plug in a URL and that URL would provide or some sort of service. That service would then provide the Entire List of macOS restore images that are available.","start":1046199,"end":1061959,"confidence":0.84375,"words":[{"text":"The.","start":1046199,"end":1046439,"confidence":0.84375,"speaker":"A"},{"text":"The","start":1046679,"end":1046959,"confidence":0.9980469,"speaker":"A"},{"text":"way","start":1046959,"end":1047119,"confidence":1,"speaker":"A"},{"text":"it's","start":1047119,"end":1047319,"confidence":0.9996745,"speaker":"A"},{"text":"built","start":1047319,"end":1047559,"confidence":0.8929036,"speaker":"A"},{"text":"right","start":1047559,"end":1047759,"confidence":0.9995117,"speaker":"A"},{"text":"now","start":1047759,"end":1047959,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1047959,"end":1048199,"confidence":0.9667969,"speaker":"A"},{"text":"I","start":1048199,"end":1048359,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":1048359,"end":1048479,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":1048479,"end":1048679,"confidence":0.9995117,"speaker":"A"},{"text":"concept","start":1048679,"end":1049079,"confidence":0.9786784,"speaker":"A"},{"text":"of","start":1049079,"end":1049319,"confidence":0.9995117,"speaker":"A"},{"text":"hubs","start":1049319,"end":1049719,"confidence":0.9838867,"speaker":"A"},{"text":"and","start":1050679,"end":1051079,"confidence":0.96240234,"speaker":"A"},{"text":"you","start":1051159,"end":1051439,"confidence":1,"speaker":"A"},{"text":"can","start":1051439,"end":1051599,"confidence":0.99902344,"speaker":"A"},{"text":"plug","start":1051599,"end":1051799,"confidence":1,"speaker":"A"},{"text":"in","start":1051799,"end":1051919,"confidence":0.9951172,"speaker":"A"},{"text":"a","start":1051919,"end":1052079,"confidence":0.99072266,"speaker":"A"},{"text":"URL","start":1052079,"end":1052639,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":1052639,"end":1052839,"confidence":0.9628906,"speaker":"A"},{"text":"that","start":1052839,"end":1052959,"confidence":0.99902344,"speaker":"A"},{"text":"URL","start":1052959,"end":1053439,"confidence":0.9367676,"speaker":"A"},{"text":"would","start":1053439,"end":1053719,"confidence":0.99658203,"speaker":"A"},{"text":"provide","start":1053719,"end":1054039,"confidence":1,"speaker":"A"},{"text":"or","start":1054039,"end":1054399,"confidence":0.99902344,"speaker":"A"},{"text":"some","start":1054399,"end":1054679,"confidence":0.97216797,"speaker":"A"},{"text":"sort","start":1054679,"end":1054919,"confidence":0.9941406,"speaker":"A"},{"text":"of","start":1054919,"end":1055079,"confidence":0.99902344,"speaker":"A"},{"text":"service.","start":1055079,"end":1055399,"confidence":0.99902344,"speaker":"A"},{"text":"That","start":1055959,"end":1056359,"confidence":0.9980469,"speaker":"A"},{"text":"service","start":1056599,"end":1056999,"confidence":0.9980469,"speaker":"A"},{"text":"would","start":1056999,"end":1057279,"confidence":0.9941406,"speaker":"A"},{"text":"then","start":1057279,"end":1057479,"confidence":0.9916992,"speaker":"A"},{"text":"provide","start":1057479,"end":1057799,"confidence":1,"speaker":"A"},{"text":"the","start":1058359,"end":1058639,"confidence":0.9995117,"speaker":"A"},{"text":"Entire","start":1058639,"end":1058999,"confidence":0.99975586,"speaker":"A"},{"text":"List","start":1058999,"end":1059279,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1059279,"end":1059639,"confidence":0.99853516,"speaker":"A"},{"text":"macOS","start":1059719,"end":1060439,"confidence":0.76636,"speaker":"A"},{"text":"restore","start":1060439,"end":1060839,"confidence":0.98168945,"speaker":"A"},{"text":"images","start":1060839,"end":1061278,"confidence":0.9987793,"speaker":"A"},{"text":"that","start":1061278,"end":1061479,"confidence":0.9995117,"speaker":"A"},{"text":"are","start":1061479,"end":1061638,"confidence":0.9995117,"speaker":"A"},{"text":"available.","start":1061638,"end":1061959,"confidence":0.9995117,"speaker":"A"}]},{"text":"But then I realized like really there's only one location for those and each service is just going to be using the same URLs anyway. So if I had one central repository or one central database because they all pull from Apple, I can then parse the web for those restore images and then store them in CloudKit and then that way Bushel can then pull those from one single repository. And all I would have to do, and what I'm doing now is running basically a GitHub action or you could do like a Cron job where it would run on Ubuntu, wouldn't even need a Mac and it would download and scrape the web for restore images and storm in the public database. It's the same idea with Celestra. It's an RSS reader.","start":1064119,"end":1109110,"confidence":0.9941406,"words":[{"text":"But","start":1064119,"end":1064399,"confidence":0.9941406,"speaker":"A"},{"text":"then","start":1064399,"end":1064559,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1064559,"end":1064719,"confidence":0.9995117,"speaker":"A"},{"text":"realized","start":1064719,"end":1065079,"confidence":0.9863281,"speaker":"A"},{"text":"like","start":1065079,"end":1065319,"confidence":0.90283203,"speaker":"A"},{"text":"really","start":1065319,"end":1065559,"confidence":0.9970703,"speaker":"A"},{"text":"there's","start":1065559,"end":1065839,"confidence":0.9889323,"speaker":"A"},{"text":"only","start":1065839,"end":1065999,"confidence":0.9995117,"speaker":"A"},{"text":"one","start":1065999,"end":1066199,"confidence":0.9995117,"speaker":"A"},{"text":"location","start":1066199,"end":1066679,"confidence":1,"speaker":"A"},{"text":"for","start":1066679,"end":1066919,"confidence":0.9995117,"speaker":"A"},{"text":"those","start":1066919,"end":1067239,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1067319,"end":1067719,"confidence":0.98876953,"speaker":"A"},{"text":"each","start":1067719,"end":1068079,"confidence":0.9824219,"speaker":"A"},{"text":"service","start":1068079,"end":1068399,"confidence":0.9951172,"speaker":"A"},{"text":"is","start":1068399,"end":1068639,"confidence":0.99853516,"speaker":"A"},{"text":"just","start":1068639,"end":1068799,"confidence":0.99609375,"speaker":"A"},{"text":"going","start":1068799,"end":1068919,"confidence":0.8798828,"speaker":"A"},{"text":"to","start":1068919,"end":1068999,"confidence":0.99902344,"speaker":"A"},{"text":"be","start":1068999,"end":1069079,"confidence":0.99853516,"speaker":"A"},{"text":"using","start":1069079,"end":1069319,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1069319,"end":1069559,"confidence":0.9995117,"speaker":"A"},{"text":"same","start":1069559,"end":1069719,"confidence":0.9995117,"speaker":"A"},{"text":"URLs","start":1069719,"end":1070359,"confidence":0.92261,"speaker":"A"},{"text":"anyway.","start":1070359,"end":1070839,"confidence":0.99731445,"speaker":"A"},{"text":"So","start":1071970,"end":1072050,"confidence":0.92822266,"speaker":"A"},{"text":"if","start":1072050,"end":1072170,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1072170,"end":1072330,"confidence":0.9995117,"speaker":"A"},{"text":"had","start":1072330,"end":1072570,"confidence":0.9975586,"speaker":"A"},{"text":"one","start":1072570,"end":1072850,"confidence":0.9995117,"speaker":"A"},{"text":"central","start":1072850,"end":1073170,"confidence":1,"speaker":"A"},{"text":"repository","start":1073250,"end":1074050,"confidence":0.9127197,"speaker":"A"},{"text":"or","start":1074050,"end":1074250,"confidence":0.99853516,"speaker":"A"},{"text":"one","start":1074250,"end":1074450,"confidence":0.9970703,"speaker":"A"},{"text":"central","start":1074450,"end":1074770,"confidence":1,"speaker":"A"},{"text":"database","start":1074770,"end":1075490,"confidence":1,"speaker":"A"},{"text":"because","start":1076850,"end":1077170,"confidence":0.99365234,"speaker":"A"},{"text":"they","start":1077170,"end":1077370,"confidence":0.9975586,"speaker":"A"},{"text":"all","start":1077370,"end":1077530,"confidence":0.99902344,"speaker":"A"},{"text":"pull","start":1077530,"end":1077770,"confidence":0.99975586,"speaker":"A"},{"text":"from","start":1077770,"end":1077970,"confidence":0.9995117,"speaker":"A"},{"text":"Apple,","start":1077970,"end":1078450,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1078690,"end":1079010,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1079010,"end":1079210,"confidence":0.99365234,"speaker":"A"},{"text":"then","start":1079210,"end":1079490,"confidence":0.98828125,"speaker":"A"},{"text":"parse","start":1079650,"end":1080250,"confidence":0.8129883,"speaker":"A"},{"text":"the","start":1080250,"end":1080490,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1080490,"end":1080850,"confidence":0.99975586,"speaker":"A"},{"text":"for","start":1081090,"end":1081410,"confidence":0.59033203,"speaker":"A"},{"text":"those","start":1081410,"end":1081690,"confidence":0.99902344,"speaker":"A"},{"text":"restore","start":1081690,"end":1082210,"confidence":0.98779297,"speaker":"A"},{"text":"images","start":1082210,"end":1082690,"confidence":0.99780273,"speaker":"A"},{"text":"and","start":1082690,"end":1082930,"confidence":0.99072266,"speaker":"A"},{"text":"then","start":1082930,"end":1083090,"confidence":0.99658203,"speaker":"A"},{"text":"store","start":1083090,"end":1083370,"confidence":0.9736328,"speaker":"A"},{"text":"them","start":1083370,"end":1083530,"confidence":0.9238281,"speaker":"A"},{"text":"in","start":1083530,"end":1083650,"confidence":0.98779297,"speaker":"A"},{"text":"CloudKit","start":1083650,"end":1084210,"confidence":0.94812,"speaker":"A"},{"text":"and","start":1084210,"end":1084370,"confidence":0.8354492,"speaker":"A"},{"text":"then","start":1084370,"end":1084530,"confidence":0.9873047,"speaker":"A"},{"text":"that","start":1084530,"end":1084770,"confidence":0.9980469,"speaker":"A"},{"text":"way","start":1084770,"end":1085090,"confidence":0.99853516,"speaker":"A"},{"text":"Bushel","start":1085410,"end":1086010,"confidence":0.8808594,"speaker":"A"},{"text":"can","start":1086010,"end":1086170,"confidence":0.9501953,"speaker":"A"},{"text":"then","start":1086170,"end":1086450,"confidence":0.95751953,"speaker":"A"},{"text":"pull","start":1087570,"end":1087930,"confidence":0.9995117,"speaker":"A"},{"text":"those","start":1087930,"end":1088210,"confidence":0.9975586,"speaker":"A"},{"text":"from","start":1088210,"end":1088530,"confidence":1,"speaker":"A"},{"text":"one","start":1088530,"end":1088770,"confidence":0.9995117,"speaker":"A"},{"text":"single","start":1088770,"end":1089090,"confidence":1,"speaker":"A"},{"text":"repository.","start":1089090,"end":1089970,"confidence":0.9998779,"speaker":"A"},{"text":"And","start":1090210,"end":1090490,"confidence":0.86572266,"speaker":"A"},{"text":"all","start":1090490,"end":1090650,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":1090650,"end":1090770,"confidence":0.98291016,"speaker":"A"},{"text":"would","start":1090770,"end":1090930,"confidence":0.98583984,"speaker":"A"},{"text":"have","start":1090930,"end":1091090,"confidence":1,"speaker":"A"},{"text":"to","start":1091090,"end":1091210,"confidence":0.99902344,"speaker":"A"},{"text":"do,","start":1091210,"end":1091450,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1091450,"end":1091770,"confidence":0.64404297,"speaker":"A"},{"text":"what","start":1091770,"end":1092010,"confidence":0.9995117,"speaker":"A"},{"text":"I'm","start":1092010,"end":1092210,"confidence":0.99934894,"speaker":"A"},{"text":"doing","start":1092210,"end":1092410,"confidence":1,"speaker":"A"},{"text":"now","start":1092410,"end":1092690,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":1092690,"end":1092930,"confidence":0.99902344,"speaker":"A"},{"text":"running","start":1092930,"end":1093370,"confidence":0.99121094,"speaker":"A"},{"text":"basically","start":1093370,"end":1093850,"confidence":0.998291,"speaker":"A"},{"text":"a","start":1093850,"end":1094090,"confidence":0.9951172,"speaker":"A"},{"text":"GitHub","start":1094090,"end":1094490,"confidence":0.9991862,"speaker":"A"},{"text":"action","start":1094490,"end":1094690,"confidence":1,"speaker":"A"},{"text":"or","start":1094690,"end":1094850,"confidence":0.98828125,"speaker":"A"},{"text":"you","start":1094850,"end":1094930,"confidence":0.91503906,"speaker":"A"},{"text":"could","start":1094930,"end":1095050,"confidence":0.8876953,"speaker":"A"},{"text":"do","start":1095050,"end":1095210,"confidence":0.99853516,"speaker":"A"},{"text":"like","start":1095210,"end":1095370,"confidence":0.8642578,"speaker":"A"},{"text":"a","start":1095370,"end":1095490,"confidence":0.9868164,"speaker":"A"},{"text":"Cron","start":1095490,"end":1095770,"confidence":0.97875977,"speaker":"A"},{"text":"job","start":1095770,"end":1096050,"confidence":1,"speaker":"A"},{"text":"where","start":1096450,"end":1096850,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":1096850,"end":1097130,"confidence":0.99560547,"speaker":"A"},{"text":"would","start":1097130,"end":1097290,"confidence":1,"speaker":"A"},{"text":"run","start":1097290,"end":1097450,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":1097450,"end":1097610,"confidence":0.9824219,"speaker":"A"},{"text":"Ubuntu,","start":1097610,"end":1098090,"confidence":0.8498047,"speaker":"A"},{"text":"wouldn't","start":1098090,"end":1098370,"confidence":0.9715576,"speaker":"A"},{"text":"even","start":1098370,"end":1098490,"confidence":0.99853516,"speaker":"A"},{"text":"need","start":1098490,"end":1098650,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1098650,"end":1098810,"confidence":0.99853516,"speaker":"A"},{"text":"Mac","start":1098810,"end":1099090,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":1099090,"end":1099290,"confidence":0.96240234,"speaker":"A"},{"text":"it","start":1099290,"end":1099450,"confidence":0.99853516,"speaker":"A"},{"text":"would","start":1099450,"end":1099730,"confidence":0.9995117,"speaker":"A"},{"text":"download","start":1099890,"end":1100490,"confidence":1,"speaker":"A"},{"text":"and","start":1100490,"end":1100730,"confidence":0.59228516,"speaker":"A"},{"text":"scrape","start":1100730,"end":1101130,"confidence":0.8902588,"speaker":"A"},{"text":"the","start":1101130,"end":1101290,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1101290,"end":1101530,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":1101530,"end":1101770,"confidence":0.9970703,"speaker":"A"},{"text":"restore","start":1101770,"end":1102250,"confidence":0.9777832,"speaker":"A"},{"text":"images","start":1102250,"end":1102650,"confidence":0.99731445,"speaker":"A"},{"text":"and","start":1102650,"end":1103000,"confidence":0.52197266,"speaker":"A"},{"text":"storm","start":1103070,"end":1103350,"confidence":0.92749023,"speaker":"A"},{"text":"in","start":1103350,"end":1103470,"confidence":0.9951172,"speaker":"A"},{"text":"the","start":1103470,"end":1103590,"confidence":0.99902344,"speaker":"A"},{"text":"public","start":1103590,"end":1103790,"confidence":1,"speaker":"A"},{"text":"database.","start":1103790,"end":1104430,"confidence":0.99820966,"speaker":"A"},{"text":"It's","start":1106350,"end":1106710,"confidence":0.9967448,"speaker":"A"},{"text":"the","start":1106710,"end":1106830,"confidence":0.9995117,"speaker":"A"},{"text":"same","start":1106830,"end":1106950,"confidence":1,"speaker":"A"},{"text":"idea","start":1106950,"end":1107230,"confidence":0.99902344,"speaker":"A"},{"text":"with","start":1107230,"end":1107350,"confidence":0.98779297,"speaker":"A"},{"text":"Celestra.","start":1107350,"end":1107910,"confidence":0.9313151,"speaker":"A"},{"text":"It's","start":1107910,"end":1108110,"confidence":0.99283856,"speaker":"A"},{"text":"an","start":1108110,"end":1108190,"confidence":0.73876953,"speaker":"A"},{"text":"RSS","start":1108190,"end":1108630,"confidence":0.9946289,"speaker":"A"},{"text":"reader.","start":1108630,"end":1109110,"confidence":0.99902344,"speaker":"A"}]},{"text":"What if I took those RSS RSS files in the web and just scrape them and then store them in a CloudKit database in a public database and then that way people can pull that up all through CloudKit.","start":1109110,"end":1122910,"confidence":0.9995117,"words":[{"text":"What","start":1109110,"end":1109270,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1109270,"end":1109430,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1109430,"end":1109630,"confidence":0.9995117,"speaker":"A"},{"text":"took","start":1109630,"end":1109870,"confidence":0.99902344,"speaker":"A"},{"text":"those","start":1109870,"end":1110070,"confidence":0.9946289,"speaker":"A"},{"text":"RSS","start":1110070,"end":1110590,"confidence":0.98535156,"speaker":"A"},{"text":"RSS","start":1112750,"end":1113310,"confidence":0.94921875,"speaker":"A"},{"text":"files","start":1113310,"end":1113670,"confidence":0.95703125,"speaker":"A"},{"text":"in","start":1113670,"end":1113830,"confidence":0.99365234,"speaker":"A"},{"text":"the","start":1113830,"end":1113950,"confidence":1,"speaker":"A"},{"text":"web","start":1113950,"end":1114150,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":1114150,"end":1114350,"confidence":0.8354492,"speaker":"A"},{"text":"just","start":1114350,"end":1114630,"confidence":0.99853516,"speaker":"A"},{"text":"scrape","start":1114630,"end":1115110,"confidence":0.8651123,"speaker":"A"},{"text":"them","start":1115110,"end":1115270,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1115270,"end":1115430,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":1115430,"end":1115630,"confidence":0.9970703,"speaker":"A"},{"text":"store","start":1115630,"end":1115950,"confidence":0.97753906,"speaker":"A"},{"text":"them","start":1115950,"end":1116070,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1116070,"end":1116190,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1116190,"end":1116270,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":1116270,"end":1116830,"confidence":0.9890137,"speaker":"A"},{"text":"database","start":1116830,"end":1117470,"confidence":0.9996745,"speaker":"A"},{"text":"in","start":1118110,"end":1118430,"confidence":0.8745117,"speaker":"A"},{"text":"a","start":1118430,"end":1118590,"confidence":0.99902344,"speaker":"A"},{"text":"public","start":1118590,"end":1118750,"confidence":1,"speaker":"A"},{"text":"database","start":1118750,"end":1119390,"confidence":0.9998372,"speaker":"A"},{"text":"and","start":1119390,"end":1119550,"confidence":0.99316406,"speaker":"A"},{"text":"then","start":1119550,"end":1119710,"confidence":0.9741211,"speaker":"A"},{"text":"that","start":1119710,"end":1119910,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":1119910,"end":1120110,"confidence":1,"speaker":"A"},{"text":"people","start":1120110,"end":1120390,"confidence":1,"speaker":"A"},{"text":"can","start":1120390,"end":1120750,"confidence":0.9995117,"speaker":"A"},{"text":"pull","start":1120750,"end":1121110,"confidence":1,"speaker":"A"},{"text":"that","start":1121110,"end":1121310,"confidence":0.99853516,"speaker":"A"},{"text":"up","start":1121310,"end":1121630,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":1121630,"end":1121910,"confidence":0.9980469,"speaker":"A"},{"text":"through","start":1121910,"end":1122110,"confidence":1,"speaker":"A"},{"text":"CloudKit.","start":1122110,"end":1122910,"confidence":0.845459,"speaker":"A"}]},{"text":"So the idea today is we're going to talk about how to set something, how I set something like this up and how you could use use my library to then go ahead and do this yourself for any sort of work that you're going to do that where you want to use either a public or private database in CloudKit. So this is where I introduce myself. So I'm going to talk today about building Miskit, which is my library I built for doing CloudKit stuff on the server or essentially off of, not off of Apple platforms.","start":1125150,"end":1157140,"confidence":0.9873047,"words":[{"text":"So","start":1125150,"end":1125550,"confidence":0.9873047,"speaker":"A"},{"text":"the","start":1125630,"end":1125910,"confidence":0.99902344,"speaker":"A"},{"text":"idea","start":1125910,"end":1126270,"confidence":1,"speaker":"A"},{"text":"today","start":1126270,"end":1126550,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1126550,"end":1126790,"confidence":0.9980469,"speaker":"A"},{"text":"we're","start":1126790,"end":1127030,"confidence":0.9991862,"speaker":"A"},{"text":"going","start":1127030,"end":1127150,"confidence":0.88671875,"speaker":"A"},{"text":"to","start":1127150,"end":1127230,"confidence":1,"speaker":"A"},{"text":"talk","start":1127230,"end":1127390,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":1127390,"end":1127710,"confidence":0.9975586,"speaker":"A"},{"text":"how","start":1128030,"end":1128350,"confidence":0.99365234,"speaker":"A"},{"text":"to","start":1128350,"end":1128550,"confidence":0.9707031,"speaker":"A"},{"text":"set","start":1128550,"end":1128750,"confidence":0.99853516,"speaker":"A"},{"text":"something,","start":1128750,"end":1129070,"confidence":0.95947266,"speaker":"A"},{"text":"how","start":1129070,"end":1129430,"confidence":0.9814453,"speaker":"A"},{"text":"I","start":1129430,"end":1129710,"confidence":0.99560547,"speaker":"A"},{"text":"set","start":1129710,"end":1129990,"confidence":0.99658203,"speaker":"A"},{"text":"something","start":1129990,"end":1130310,"confidence":1,"speaker":"A"},{"text":"like","start":1130310,"end":1130550,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":1130550,"end":1130750,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":1130750,"end":1131070,"confidence":0.99560547,"speaker":"A"},{"text":"and","start":1131860,"end":1132100,"confidence":0.9321289,"speaker":"A"},{"text":"how","start":1132100,"end":1132380,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1132380,"end":1132540,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":1132540,"end":1132740,"confidence":0.99560547,"speaker":"A"},{"text":"use","start":1132740,"end":1133060,"confidence":0.9277344,"speaker":"A"},{"text":"use","start":1133300,"end":1133580,"confidence":1,"speaker":"A"},{"text":"my","start":1133580,"end":1133780,"confidence":0.99121094,"speaker":"A"},{"text":"library","start":1133780,"end":1134260,"confidence":0.9998372,"speaker":"A"},{"text":"to","start":1134260,"end":1134460,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":1134460,"end":1134620,"confidence":0.9980469,"speaker":"A"},{"text":"go","start":1134620,"end":1134780,"confidence":0.99902344,"speaker":"A"},{"text":"ahead","start":1134780,"end":1134980,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1134980,"end":1135220,"confidence":0.53125,"speaker":"A"},{"text":"do","start":1135220,"end":1135420,"confidence":1,"speaker":"A"},{"text":"this","start":1135420,"end":1135620,"confidence":1,"speaker":"A"},{"text":"yourself","start":1135620,"end":1136060,"confidence":0.99975586,"speaker":"A"},{"text":"for","start":1136060,"end":1136340,"confidence":0.9995117,"speaker":"A"},{"text":"any","start":1136340,"end":1136660,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":1136660,"end":1136980,"confidence":0.9975586,"speaker":"A"},{"text":"of","start":1136980,"end":1137100,"confidence":0.9995117,"speaker":"A"},{"text":"work","start":1137100,"end":1137340,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1137340,"end":1137580,"confidence":0.99853516,"speaker":"A"},{"text":"you're","start":1137580,"end":1137780,"confidence":0.99886066,"speaker":"A"},{"text":"going","start":1137780,"end":1137860,"confidence":0.7861328,"speaker":"A"},{"text":"to","start":1137860,"end":1137940,"confidence":0.99853516,"speaker":"A"},{"text":"do","start":1137940,"end":1138060,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1138060,"end":1138260,"confidence":0.9140625,"speaker":"A"},{"text":"where","start":1138260,"end":1138460,"confidence":0.9970703,"speaker":"A"},{"text":"you","start":1138460,"end":1138580,"confidence":1,"speaker":"A"},{"text":"want","start":1138580,"end":1138700,"confidence":0.9140625,"speaker":"A"},{"text":"to","start":1138700,"end":1138860,"confidence":0.9941406,"speaker":"A"},{"text":"use","start":1138860,"end":1139100,"confidence":0.99609375,"speaker":"A"},{"text":"either","start":1139100,"end":1139420,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":1139420,"end":1139580,"confidence":0.9238281,"speaker":"A"},{"text":"public","start":1139580,"end":1139780,"confidence":1,"speaker":"A"},{"text":"or","start":1139780,"end":1140020,"confidence":1,"speaker":"A"},{"text":"private","start":1140020,"end":1140300,"confidence":1,"speaker":"A"},{"text":"database","start":1140300,"end":1140980,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1141220,"end":1141500,"confidence":0.7890625,"speaker":"A"},{"text":"CloudKit.","start":1141500,"end":1142180,"confidence":0.99560547,"speaker":"A"},{"text":"So","start":1143300,"end":1143540,"confidence":0.9873047,"speaker":"A"},{"text":"this","start":1143540,"end":1143660,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1143660,"end":1143820,"confidence":1,"speaker":"A"},{"text":"where","start":1143820,"end":1143980,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1143980,"end":1144140,"confidence":0.97509766,"speaker":"A"},{"text":"introduce","start":1144140,"end":1144580,"confidence":0.96435547,"speaker":"A"},{"text":"myself.","start":1144580,"end":1145060,"confidence":0.99487305,"speaker":"A"},{"text":"So","start":1145940,"end":1146180,"confidence":0.9741211,"speaker":"A"},{"text":"I'm","start":1146180,"end":1146340,"confidence":0.99690753,"speaker":"A"},{"text":"going","start":1146340,"end":1146420,"confidence":0.9428711,"speaker":"A"},{"text":"to","start":1146420,"end":1146500,"confidence":0.99853516,"speaker":"A"},{"text":"talk","start":1146500,"end":1146660,"confidence":0.9995117,"speaker":"A"},{"text":"today","start":1146660,"end":1146860,"confidence":0.99121094,"speaker":"A"},{"text":"about","start":1146860,"end":1147020,"confidence":1,"speaker":"A"},{"text":"building","start":1147020,"end":1147299,"confidence":0.9995117,"speaker":"A"},{"text":"Miskit,","start":1147299,"end":1148020,"confidence":0.82421875,"speaker":"A"},{"text":"which","start":1148260,"end":1148540,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1148540,"end":1148700,"confidence":0.99072266,"speaker":"A"},{"text":"my","start":1148700,"end":1148860,"confidence":0.9995117,"speaker":"A"},{"text":"library","start":1148860,"end":1149300,"confidence":1,"speaker":"A"},{"text":"I","start":1149300,"end":1149500,"confidence":0.99853516,"speaker":"A"},{"text":"built","start":1149500,"end":1149860,"confidence":0.96761066,"speaker":"A"},{"text":"for","start":1150340,"end":1150700,"confidence":0.9921875,"speaker":"A"},{"text":"doing","start":1150700,"end":1151060,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":1151460,"end":1152100,"confidence":0.99609375,"speaker":"A"},{"text":"stuff","start":1152100,"end":1152580,"confidence":0.99886066,"speaker":"A"},{"text":"on","start":1152740,"end":1153020,"confidence":0.94628906,"speaker":"A"},{"text":"the","start":1153020,"end":1153180,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1153180,"end":1153540,"confidence":1,"speaker":"A"},{"text":"or","start":1153540,"end":1153740,"confidence":0.9951172,"speaker":"A"},{"text":"essentially","start":1153740,"end":1154180,"confidence":0.9970703,"speaker":"A"},{"text":"off","start":1154180,"end":1154420,"confidence":0.8652344,"speaker":"A"},{"text":"of,","start":1154420,"end":1154740,"confidence":0.9970703,"speaker":"A"},{"text":"not","start":1155380,"end":1155660,"confidence":0.99853516,"speaker":"A"},{"text":"off","start":1155660,"end":1155860,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1155860,"end":1156100,"confidence":0.9970703,"speaker":"A"},{"text":"Apple","start":1156100,"end":1156500,"confidence":0.99975586,"speaker":"A"},{"text":"platforms.","start":1156500,"end":1157140,"confidence":0.9978841,"speaker":"A"}]},{"text":"Evan, do you have any questions before I keep going? No, it's good. Good topic though. So like I said, we have CloudKit Web Services and CloudKit Web Services. We provide a lot of documentation.","start":1159770,"end":1174210,"confidence":0.9189453,"words":[{"text":"Evan,","start":1159770,"end":1160050,"confidence":0.9189453,"speaker":"A"},{"text":"do","start":1160050,"end":1160170,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1160170,"end":1160250,"confidence":0.9873047,"speaker":"A"},{"text":"have","start":1160250,"end":1160330,"confidence":0.9995117,"speaker":"A"},{"text":"any","start":1160330,"end":1160450,"confidence":0.99902344,"speaker":"A"},{"text":"questions","start":1160450,"end":1160850,"confidence":0.99975586,"speaker":"A"},{"text":"before","start":1160850,"end":1161010,"confidence":1,"speaker":"A"},{"text":"I","start":1161010,"end":1161170,"confidence":0.99853516,"speaker":"A"},{"text":"keep","start":1161170,"end":1161330,"confidence":0.99902344,"speaker":"A"},{"text":"going?","start":1161330,"end":1161610,"confidence":0.99902344,"speaker":"A"},{"text":"No,","start":1162730,"end":1163130,"confidence":0.9770508,"speaker":"B"},{"text":"it's","start":1163370,"end":1163730,"confidence":0.9757487,"speaker":"B"},{"text":"good.","start":1163730,"end":1163970,"confidence":0.6723633,"speaker":"B"},{"text":"Good","start":1163970,"end":1164250,"confidence":1,"speaker":"B"},{"text":"topic","start":1164250,"end":1164610,"confidence":0.9953613,"speaker":"B"},{"text":"though.","start":1164610,"end":1164890,"confidence":0.99072266,"speaker":"B"},{"text":"So","start":1166810,"end":1167090,"confidence":0.9042969,"speaker":"A"},{"text":"like","start":1167090,"end":1167250,"confidence":0.9951172,"speaker":"A"},{"text":"I","start":1167250,"end":1167410,"confidence":1,"speaker":"A"},{"text":"said,","start":1167410,"end":1167610,"confidence":1,"speaker":"A"},{"text":"we","start":1167610,"end":1167810,"confidence":1,"speaker":"A"},{"text":"have","start":1167810,"end":1167970,"confidence":1,"speaker":"A"},{"text":"CloudKit","start":1167970,"end":1168570,"confidence":0.86804,"speaker":"A"},{"text":"Web","start":1168570,"end":1168810,"confidence":0.99853516,"speaker":"A"},{"text":"Services","start":1168810,"end":1169050,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":1170170,"end":1170530,"confidence":0.8461914,"speaker":"A"},{"text":"CloudKit","start":1170530,"end":1171090,"confidence":0.9489746,"speaker":"A"},{"text":"Web","start":1171090,"end":1171330,"confidence":0.9975586,"speaker":"A"},{"text":"Services.","start":1171330,"end":1171610,"confidence":0.99902344,"speaker":"A"},{"text":"We","start":1172330,"end":1172730,"confidence":0.53759766,"speaker":"A"},{"text":"provide","start":1172730,"end":1173090,"confidence":1,"speaker":"A"},{"text":"a","start":1173090,"end":1173329,"confidence":0.96240234,"speaker":"A"},{"text":"lot","start":1173329,"end":1173489,"confidence":1,"speaker":"A"},{"text":"of","start":1173489,"end":1173610,"confidence":0.99853516,"speaker":"A"},{"text":"documentation.","start":1173610,"end":1174210,"confidence":0.99990237,"speaker":"A"}]},{"text":"We talked about CloudKit JS and the instructions on how to compose a web service request which has everything I need to compose one. And back in 2020 I did this all manually.","start":1174210,"end":1184570,"confidence":0.99902344,"words":[{"text":"We","start":1174210,"end":1174450,"confidence":0.99902344,"speaker":"A"},{"text":"talked","start":1174450,"end":1174650,"confidence":0.9987793,"speaker":"A"},{"text":"about","start":1174650,"end":1174770,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":1174770,"end":1175330,"confidence":0.9980469,"speaker":"A"},{"text":"JS","start":1175330,"end":1175770,"confidence":0.7067871,"speaker":"A"},{"text":"and","start":1175850,"end":1176170,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":1176170,"end":1176370,"confidence":0.9819336,"speaker":"A"},{"text":"instructions","start":1176370,"end":1176890,"confidence":0.9773763,"speaker":"A"},{"text":"on","start":1176890,"end":1177090,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":1177090,"end":1177290,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1177290,"end":1177530,"confidence":0.9995117,"speaker":"A"},{"text":"compose","start":1177530,"end":1177930,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1177930,"end":1178090,"confidence":0.9926758,"speaker":"A"},{"text":"web","start":1178090,"end":1178410,"confidence":0.9980469,"speaker":"A"},{"text":"service","start":1178650,"end":1179050,"confidence":0.9902344,"speaker":"A"},{"text":"request","start":1179050,"end":1179570,"confidence":0.99853516,"speaker":"A"},{"text":"which","start":1179570,"end":1179810,"confidence":0.99902344,"speaker":"A"},{"text":"has","start":1179810,"end":1180090,"confidence":0.9975586,"speaker":"A"},{"text":"everything","start":1180090,"end":1180450,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1180450,"end":1180730,"confidence":0.9980469,"speaker":"A"},{"text":"need","start":1180730,"end":1181050,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":1181210,"end":1181490,"confidence":0.99853516,"speaker":"A"},{"text":"compose","start":1181490,"end":1181810,"confidence":0.99487305,"speaker":"A"},{"text":"one.","start":1181810,"end":1182050,"confidence":0.57421875,"speaker":"A"},{"text":"And","start":1182050,"end":1182370,"confidence":0.81640625,"speaker":"A"},{"text":"back","start":1182370,"end":1182610,"confidence":1,"speaker":"A"},{"text":"in","start":1182610,"end":1182810,"confidence":0.9995117,"speaker":"A"},{"text":"2020","start":1182810,"end":1183370,"confidence":0.9978,"speaker":"A"},{"text":"I","start":1183370,"end":1183610,"confidence":0.9995117,"speaker":"A"},{"text":"did","start":1183610,"end":1183730,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":1183730,"end":1183890,"confidence":0.98535156,"speaker":"A"},{"text":"all","start":1183890,"end":1184090,"confidence":0.99316406,"speaker":"A"},{"text":"manually.","start":1184090,"end":1184570,"confidence":0.9992676,"speaker":"A"}]},{"text":"The thing is at this point, if you look at right there, actually if you look at the top, you can see it hasn't been updated in over 10 years, which is kind of crazy, but it works. And then we got introduced to something back in WWDC I want to say it was 23.","start":1186600,"end":1208200,"confidence":0.9946289,"words":[{"text":"The","start":1186600,"end":1186760,"confidence":0.9946289,"speaker":"A"},{"text":"thing","start":1186760,"end":1187000,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1187000,"end":1187240,"confidence":0.99902344,"speaker":"A"},{"text":"at","start":1187240,"end":1187440,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":1187440,"end":1187640,"confidence":0.9995117,"speaker":"A"},{"text":"point,","start":1187640,"end":1187960,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1188600,"end":1188880,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1188880,"end":1189040,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":1189040,"end":1189200,"confidence":0.9995117,"speaker":"A"},{"text":"at","start":1189200,"end":1189440,"confidence":0.9814453,"speaker":"A"},{"text":"right","start":1189440,"end":1189720,"confidence":0.99902344,"speaker":"A"},{"text":"there,","start":1189720,"end":1190040,"confidence":0.99902344,"speaker":"A"},{"text":"actually","start":1191000,"end":1191320,"confidence":0.99316406,"speaker":"A"},{"text":"if","start":1191320,"end":1191480,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1191480,"end":1191560,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":1191560,"end":1191680,"confidence":1,"speaker":"A"},{"text":"at","start":1191680,"end":1191800,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1191800,"end":1191920,"confidence":0.9995117,"speaker":"A"},{"text":"top,","start":1191920,"end":1192120,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1192120,"end":1192280,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1192280,"end":1192400,"confidence":1,"speaker":"A"},{"text":"see","start":1192400,"end":1192600,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":1192600,"end":1192760,"confidence":0.98828125,"speaker":"A"},{"text":"hasn't","start":1192760,"end":1193080,"confidence":0.99768066,"speaker":"A"},{"text":"been","start":1193080,"end":1193200,"confidence":0.9995117,"speaker":"A"},{"text":"updated","start":1193200,"end":1193560,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":1193560,"end":1193800,"confidence":0.96875,"speaker":"A"},{"text":"over","start":1193800,"end":1194120,"confidence":0.99902344,"speaker":"A"},{"text":"10","start":1194200,"end":1194480,"confidence":0.99951,"speaker":"A"},{"text":"years,","start":1194480,"end":1194760,"confidence":0.99902344,"speaker":"A"},{"text":"which","start":1196600,"end":1196880,"confidence":0.9975586,"speaker":"A"},{"text":"is","start":1196880,"end":1197160,"confidence":0.99853516,"speaker":"A"},{"text":"kind","start":1197160,"end":1197440,"confidence":0.88671875,"speaker":"A"},{"text":"of","start":1197440,"end":1197600,"confidence":0.9736328,"speaker":"A"},{"text":"crazy,","start":1197600,"end":1198120,"confidence":0.9996745,"speaker":"A"},{"text":"but","start":1198920,"end":1199200,"confidence":0.99609375,"speaker":"A"},{"text":"it","start":1199200,"end":1199360,"confidence":0.99902344,"speaker":"A"},{"text":"works.","start":1199360,"end":1199800,"confidence":0.99731445,"speaker":"A"},{"text":"And","start":1200999,"end":1201280,"confidence":0.7661133,"speaker":"A"},{"text":"then","start":1201280,"end":1201560,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":1202040,"end":1202440,"confidence":0.9975586,"speaker":"A"},{"text":"got","start":1202840,"end":1203240,"confidence":0.96191406,"speaker":"A"},{"text":"introduced","start":1204200,"end":1204800,"confidence":0.9563802,"speaker":"A"},{"text":"to","start":1204800,"end":1204960,"confidence":0.9355469,"speaker":"A"},{"text":"something","start":1204960,"end":1205200,"confidence":0.9970703,"speaker":"A"},{"text":"back","start":1205200,"end":1205440,"confidence":0.9951172,"speaker":"A"},{"text":"in","start":1205440,"end":1205600,"confidence":0.9897461,"speaker":"A"},{"text":"WWDC","start":1205600,"end":1206520,"confidence":0.7050781,"speaker":"A"},{"text":"I","start":1206520,"end":1206760,"confidence":0.93896484,"speaker":"A"},{"text":"want","start":1206760,"end":1206840,"confidence":0.89404297,"speaker":"A"},{"text":"to","start":1206840,"end":1206920,"confidence":0.9980469,"speaker":"A"},{"text":"say","start":1206920,"end":1207040,"confidence":0.99609375,"speaker":"A"},{"text":"it","start":1207040,"end":1207160,"confidence":0.8076172,"speaker":"A"},{"text":"was","start":1207160,"end":1207400,"confidence":0.79248047,"speaker":"A"},{"text":"23.","start":1207480,"end":1208200,"confidence":0.99805,"speaker":"A"}]},{"text":"We got introduced to the Open API generator which is really nice because then we have, we can generate the Swift code if we know what the Open API documentation looks like it. And of course Apple doesn't provide one for CloudKit but they did provide a pretty big piece open. If you ever you looked at the Open API generator, it's amazing. Takes the Open API gamble file and generates all the Swift code you need. One of the other issues I had with first developing Miskit in 2020 was that there was no way to like there was no abstraction layer which could differentiate between doing something on the server or using regular like URL session which is more targeted towards client side.","start":1210280,"end":1256080,"confidence":0.99853516,"words":[{"text":"We","start":1210280,"end":1210600,"confidence":0.99853516,"speaker":"A"},{"text":"got","start":1210600,"end":1210840,"confidence":0.96240234,"speaker":"A"},{"text":"introduced","start":1210840,"end":1211360,"confidence":0.9744466,"speaker":"A"},{"text":"to","start":1211360,"end":1211520,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1211520,"end":1211680,"confidence":0.9995117,"speaker":"A"},{"text":"Open","start":1211680,"end":1211920,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1211920,"end":1212440,"confidence":0.97436523,"speaker":"A"},{"text":"generator","start":1212440,"end":1213000,"confidence":0.9851074,"speaker":"A"},{"text":"which","start":1213800,"end":1214000,"confidence":0.99365234,"speaker":"A"},{"text":"is","start":1214000,"end":1214320,"confidence":1,"speaker":"A"},{"text":"really","start":1214320,"end":1214600,"confidence":0.9995117,"speaker":"A"},{"text":"nice","start":1214600,"end":1215000,"confidence":1,"speaker":"A"},{"text":"because","start":1215000,"end":1215400,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":1215960,"end":1216360,"confidence":0.9760742,"speaker":"A"},{"text":"we","start":1216840,"end":1217160,"confidence":0.6513672,"speaker":"A"},{"text":"have,","start":1217160,"end":1217480,"confidence":0.9902344,"speaker":"A"},{"text":"we","start":1217640,"end":1217920,"confidence":0.99609375,"speaker":"A"},{"text":"can","start":1217920,"end":1218080,"confidence":0.99902344,"speaker":"A"},{"text":"generate","start":1218080,"end":1218440,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1218440,"end":1218560,"confidence":0.9975586,"speaker":"A"},{"text":"Swift","start":1218560,"end":1218840,"confidence":0.7780762,"speaker":"A"},{"text":"code","start":1218840,"end":1219120,"confidence":0.96761066,"speaker":"A"},{"text":"if","start":1219120,"end":1219280,"confidence":1,"speaker":"A"},{"text":"we","start":1219280,"end":1219440,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":1219440,"end":1219640,"confidence":0.98779297,"speaker":"A"},{"text":"what","start":1219640,"end":1219840,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1219840,"end":1220080,"confidence":0.9638672,"speaker":"A"},{"text":"Open","start":1220080,"end":1220400,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1220400,"end":1220880,"confidence":0.8979492,"speaker":"A"},{"text":"documentation","start":1220880,"end":1221720,"confidence":0.99970704,"speaker":"A"},{"text":"looks","start":1222200,"end":1222600,"confidence":1,"speaker":"A"},{"text":"like","start":1222600,"end":1222720,"confidence":0.99902344,"speaker":"A"},{"text":"it.","start":1222720,"end":1222880,"confidence":0.7519531,"speaker":"A"},{"text":"And","start":1222880,"end":1223040,"confidence":0.87597656,"speaker":"A"},{"text":"of","start":1223040,"end":1223160,"confidence":0.9980469,"speaker":"A"},{"text":"course","start":1223160,"end":1223280,"confidence":1,"speaker":"A"},{"text":"Apple","start":1223280,"end":1223600,"confidence":0.99975586,"speaker":"A"},{"text":"doesn't","start":1223600,"end":1223840,"confidence":0.99853516,"speaker":"A"},{"text":"provide","start":1223840,"end":1224080,"confidence":1,"speaker":"A"},{"text":"one","start":1224080,"end":1224320,"confidence":0.9926758,"speaker":"A"},{"text":"for","start":1224320,"end":1224480,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":1224480,"end":1225240,"confidence":0.9314,"speaker":"A"},{"text":"but","start":1225960,"end":1226280,"confidence":0.9951172,"speaker":"A"},{"text":"they","start":1226280,"end":1226480,"confidence":0.88427734,"speaker":"A"},{"text":"did","start":1226480,"end":1226720,"confidence":0.98779297,"speaker":"A"},{"text":"provide","start":1226720,"end":1227040,"confidence":1,"speaker":"A"},{"text":"a","start":1227040,"end":1227280,"confidence":0.9995117,"speaker":"A"},{"text":"pretty","start":1227280,"end":1227520,"confidence":0.9998372,"speaker":"A"},{"text":"big","start":1227520,"end":1227720,"confidence":1,"speaker":"A"},{"text":"piece","start":1227720,"end":1228120,"confidence":0.99869794,"speaker":"A"},{"text":"open.","start":1229240,"end":1229639,"confidence":0.6689453,"speaker":"A"},{"text":"If","start":1229800,"end":1230040,"confidence":0.9873047,"speaker":"A"},{"text":"you","start":1230040,"end":1230120,"confidence":0.77490234,"speaker":"A"},{"text":"ever","start":1230120,"end":1230360,"confidence":0.91748047,"speaker":"A"},{"text":"you","start":1230360,"end":1230640,"confidence":0.7763672,"speaker":"A"},{"text":"looked","start":1230640,"end":1230920,"confidence":0.9987793,"speaker":"A"},{"text":"at","start":1230920,"end":1231000,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1231000,"end":1231120,"confidence":0.99902344,"speaker":"A"},{"text":"Open","start":1231120,"end":1231320,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":1231320,"end":1231760,"confidence":0.9448242,"speaker":"A"},{"text":"generator,","start":1231760,"end":1232160,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":1232160,"end":1232400,"confidence":0.89192706,"speaker":"A"},{"text":"amazing.","start":1232400,"end":1232840,"confidence":0.9998372,"speaker":"A"},{"text":"Takes","start":1232840,"end":1233200,"confidence":0.7607422,"speaker":"A"},{"text":"the","start":1233200,"end":1233320,"confidence":0.46704102,"speaker":"A"},{"text":"Open","start":1233320,"end":1233520,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":1233520,"end":1234080,"confidence":0.9501953,"speaker":"A"},{"text":"gamble","start":1234080,"end":1234640,"confidence":0.7845052,"speaker":"A"},{"text":"file","start":1234640,"end":1235000,"confidence":0.99121094,"speaker":"A"},{"text":"and","start":1235000,"end":1235320,"confidence":0.53125,"speaker":"A"},{"text":"generates","start":1235560,"end":1236160,"confidence":0.99975586,"speaker":"A"},{"text":"all","start":1236160,"end":1236400,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1236400,"end":1236560,"confidence":0.99609375,"speaker":"A"},{"text":"Swift","start":1236560,"end":1236840,"confidence":0.7429199,"speaker":"A"},{"text":"code","start":1236840,"end":1237080,"confidence":0.9991862,"speaker":"A"},{"text":"you","start":1237080,"end":1237240,"confidence":0.99853516,"speaker":"A"},{"text":"need.","start":1237240,"end":1237560,"confidence":1,"speaker":"A"},{"text":"One","start":1237880,"end":1238160,"confidence":0.99560547,"speaker":"A"},{"text":"of","start":1238160,"end":1238320,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1238320,"end":1238440,"confidence":1,"speaker":"A"},{"text":"other","start":1238440,"end":1238600,"confidence":0.99902344,"speaker":"A"},{"text":"issues","start":1238600,"end":1238880,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1238880,"end":1239120,"confidence":0.99902344,"speaker":"A"},{"text":"had","start":1239120,"end":1239280,"confidence":0.99658203,"speaker":"A"},{"text":"with","start":1239280,"end":1239560,"confidence":0.98828125,"speaker":"A"},{"text":"first","start":1240880,"end":1241040,"confidence":0.98339844,"speaker":"A"},{"text":"developing","start":1241040,"end":1241480,"confidence":0.99902344,"speaker":"A"},{"text":"Miskit","start":1241480,"end":1242160,"confidence":0.90844727,"speaker":"A"},{"text":"in","start":1242160,"end":1242440,"confidence":0.99072266,"speaker":"A"},{"text":"2020","start":1242440,"end":1243120,"confidence":0.99658,"speaker":"A"},{"text":"was","start":1243600,"end":1243920,"confidence":0.99609375,"speaker":"A"},{"text":"that","start":1243920,"end":1244160,"confidence":0.9951172,"speaker":"A"},{"text":"there","start":1244160,"end":1244360,"confidence":1,"speaker":"A"},{"text":"was","start":1244360,"end":1244520,"confidence":0.9995117,"speaker":"A"},{"text":"no","start":1244520,"end":1244720,"confidence":1,"speaker":"A"},{"text":"way","start":1244720,"end":1245000,"confidence":1,"speaker":"A"},{"text":"to","start":1245000,"end":1245320,"confidence":0.99658203,"speaker":"A"},{"text":"like","start":1245320,"end":1245680,"confidence":0.99072266,"speaker":"A"},{"text":"there","start":1245840,"end":1246160,"confidence":0.9770508,"speaker":"A"},{"text":"was","start":1246160,"end":1246360,"confidence":0.9941406,"speaker":"A"},{"text":"no","start":1246360,"end":1246520,"confidence":0.95410156,"speaker":"A"},{"text":"abstraction","start":1246520,"end":1247120,"confidence":0.9992676,"speaker":"A"},{"text":"layer","start":1247120,"end":1247520,"confidence":0.99934894,"speaker":"A"},{"text":"which","start":1247520,"end":1247800,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":1247800,"end":1248040,"confidence":0.99316406,"speaker":"A"},{"text":"differentiate","start":1248040,"end":1248640,"confidence":0.9992676,"speaker":"A"},{"text":"between","start":1248640,"end":1248920,"confidence":1,"speaker":"A"},{"text":"doing","start":1248920,"end":1249200,"confidence":0.99902344,"speaker":"A"},{"text":"something","start":1249200,"end":1249440,"confidence":1,"speaker":"A"},{"text":"on","start":1249440,"end":1249640,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":1249640,"end":1249800,"confidence":0.98876953,"speaker":"A"},{"text":"server","start":1249800,"end":1250320,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1250720,"end":1251080,"confidence":0.99902344,"speaker":"A"},{"text":"using","start":1251080,"end":1251440,"confidence":0.9975586,"speaker":"A"},{"text":"regular","start":1251760,"end":1252400,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":1252480,"end":1252880,"confidence":0.9765625,"speaker":"A"},{"text":"URL","start":1253040,"end":1253680,"confidence":0.9951172,"speaker":"A"},{"text":"session","start":1253680,"end":1254040,"confidence":0.9991862,"speaker":"A"},{"text":"which","start":1254040,"end":1254200,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1254200,"end":1254360,"confidence":0.99658203,"speaker":"A"},{"text":"more","start":1254360,"end":1254600,"confidence":1,"speaker":"A"},{"text":"targeted","start":1254600,"end":1255080,"confidence":1,"speaker":"A"},{"text":"towards","start":1255080,"end":1255360,"confidence":0.9992676,"speaker":"A"},{"text":"client","start":1255360,"end":1255719,"confidence":0.9328613,"speaker":"A"},{"text":"side.","start":1255719,"end":1256080,"confidence":0.99853516,"speaker":"A"}]},{"text":"So I had to build my own abstraction for that. Luckily Open API has, there's open API transport I believe, which provides an abstraction layer where you can then plug in either use Async HTTP client, which is the server way of doing it, or you can plug in a URL session transport, which is of course the client way to do, provides a really great tutorial. I highly recommend checking this out as well as the doxy documentation that they provide. So this is great. But then I'd have to go ahead and I'd have to figure out a way to convert all this documentation into an open API document.","start":1258960,"end":1301140,"confidence":0.9970703,"words":[{"text":"So","start":1258960,"end":1259360,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":1259440,"end":1259720,"confidence":0.99121094,"speaker":"A"},{"text":"had","start":1259720,"end":1259880,"confidence":0.8510742,"speaker":"A"},{"text":"to","start":1259880,"end":1260000,"confidence":0.97216797,"speaker":"A"},{"text":"build","start":1260000,"end":1260120,"confidence":0.9970703,"speaker":"A"},{"text":"my","start":1260120,"end":1260280,"confidence":0.9995117,"speaker":"A"},{"text":"own","start":1260280,"end":1260440,"confidence":1,"speaker":"A"},{"text":"abstraction","start":1260440,"end":1261000,"confidence":0.90441895,"speaker":"A"},{"text":"for","start":1261000,"end":1261120,"confidence":1,"speaker":"A"},{"text":"that.","start":1261120,"end":1261280,"confidence":1,"speaker":"A"},{"text":"Luckily","start":1261280,"end":1261640,"confidence":0.99641925,"speaker":"A"},{"text":"Open","start":1261640,"end":1261840,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1261840,"end":1262440,"confidence":0.7475586,"speaker":"A"},{"text":"has,","start":1262440,"end":1262800,"confidence":0.99609375,"speaker":"A"},{"text":"there's","start":1264080,"end":1264560,"confidence":0.99820966,"speaker":"A"},{"text":"open","start":1264560,"end":1264880,"confidence":0.87109375,"speaker":"A"},{"text":"API","start":1264960,"end":1265600,"confidence":0.8029785,"speaker":"A"},{"text":"transport","start":1265600,"end":1266240,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":1266240,"end":1266520,"confidence":0.99658203,"speaker":"A"},{"text":"believe,","start":1266520,"end":1266800,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":1266880,"end":1267240,"confidence":0.9995117,"speaker":"A"},{"text":"provides","start":1267240,"end":1267600,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":1267600,"end":1267720,"confidence":0.99121094,"speaker":"A"},{"text":"abstraction","start":1267720,"end":1268400,"confidence":0.98132324,"speaker":"A"},{"text":"layer","start":1268480,"end":1268840,"confidence":0.96940106,"speaker":"A"},{"text":"where","start":1268840,"end":1269000,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1269000,"end":1269120,"confidence":1,"speaker":"A"},{"text":"can","start":1269120,"end":1269240,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":1269240,"end":1269400,"confidence":0.9975586,"speaker":"A"},{"text":"plug","start":1269400,"end":1269640,"confidence":0.9992676,"speaker":"A"},{"text":"in","start":1269640,"end":1269840,"confidence":0.9946289,"speaker":"A"},{"text":"either","start":1269840,"end":1270120,"confidence":0.9980469,"speaker":"A"},{"text":"use","start":1270120,"end":1270400,"confidence":0.99316406,"speaker":"A"},{"text":"Async","start":1270980,"end":1271420,"confidence":0.94433594,"speaker":"A"},{"text":"HTTP","start":1271420,"end":1272100,"confidence":0.9790039,"speaker":"A"},{"text":"client,","start":1272100,"end":1272620,"confidence":0.9975586,"speaker":"A"},{"text":"which","start":1272620,"end":1272900,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1272900,"end":1273140,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1273140,"end":1273420,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1273420,"end":1273900,"confidence":0.99902344,"speaker":"A"},{"text":"way","start":1273900,"end":1274060,"confidence":0.98583984,"speaker":"A"},{"text":"of","start":1274060,"end":1274220,"confidence":1,"speaker":"A"},{"text":"doing","start":1274220,"end":1274380,"confidence":1,"speaker":"A"},{"text":"it,","start":1274380,"end":1274540,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1274540,"end":1274780,"confidence":0.59228516,"speaker":"A"},{"text":"you","start":1274780,"end":1275020,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1275020,"end":1275180,"confidence":0.9995117,"speaker":"A"},{"text":"plug","start":1275180,"end":1275380,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":1275380,"end":1275500,"confidence":0.99658203,"speaker":"A"},{"text":"a","start":1275500,"end":1275660,"confidence":0.99609375,"speaker":"A"},{"text":"URL","start":1275660,"end":1276180,"confidence":0.99853516,"speaker":"A"},{"text":"session","start":1276180,"end":1276660,"confidence":0.87906903,"speaker":"A"},{"text":"transport,","start":1277060,"end":1277780,"confidence":0.99902344,"speaker":"A"},{"text":"which","start":1277860,"end":1278180,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1278180,"end":1278500,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1278500,"end":1278780,"confidence":0.5307617,"speaker":"A"},{"text":"course","start":1278780,"end":1278940,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1278940,"end":1279100,"confidence":0.5600586,"speaker":"A"},{"text":"client","start":1279100,"end":1279380,"confidence":0.99487305,"speaker":"A"},{"text":"way","start":1279380,"end":1279580,"confidence":0.9941406,"speaker":"A"},{"text":"to","start":1279580,"end":1279700,"confidence":0.9995117,"speaker":"A"},{"text":"do,","start":1279700,"end":1279820,"confidence":0.9995117,"speaker":"A"},{"text":"provides","start":1282060,"end":1282420,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1282420,"end":1282540,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":1282540,"end":1282700,"confidence":0.9995117,"speaker":"A"},{"text":"great","start":1282700,"end":1282980,"confidence":0.9995117,"speaker":"A"},{"text":"tutorial.","start":1283060,"end":1283740,"confidence":0.9855957,"speaker":"A"},{"text":"I","start":1283740,"end":1283980,"confidence":0.96777344,"speaker":"A"},{"text":"highly","start":1283980,"end":1284300,"confidence":0.998291,"speaker":"A"},{"text":"recommend","start":1284300,"end":1284620,"confidence":1,"speaker":"A"},{"text":"checking","start":1284620,"end":1284900,"confidence":0.99934894,"speaker":"A"},{"text":"this","start":1284900,"end":1285060,"confidence":0.9951172,"speaker":"A"},{"text":"out","start":1285060,"end":1285380,"confidence":0.9970703,"speaker":"A"},{"text":"as","start":1286579,"end":1286859,"confidence":1,"speaker":"A"},{"text":"well","start":1286859,"end":1287020,"confidence":1,"speaker":"A"},{"text":"as","start":1287020,"end":1287300,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1287380,"end":1287740,"confidence":0.9975586,"speaker":"A"},{"text":"doxy","start":1287740,"end":1288340,"confidence":0.84684247,"speaker":"A"},{"text":"documentation","start":1288340,"end":1289060,"confidence":0.99990237,"speaker":"A"},{"text":"that","start":1289220,"end":1289500,"confidence":0.99853516,"speaker":"A"},{"text":"they","start":1289500,"end":1289700,"confidence":0.9995117,"speaker":"A"},{"text":"provide.","start":1289700,"end":1290020,"confidence":0.9970703,"speaker":"A"},{"text":"So","start":1291860,"end":1292220,"confidence":0.9667969,"speaker":"A"},{"text":"this","start":1292220,"end":1292460,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1292460,"end":1292660,"confidence":0.95654297,"speaker":"A"},{"text":"great.","start":1292660,"end":1292940,"confidence":1,"speaker":"A"},{"text":"But","start":1292940,"end":1293180,"confidence":0.99609375,"speaker":"A"},{"text":"then","start":1293180,"end":1293420,"confidence":0.99853516,"speaker":"A"},{"text":"I'd","start":1293420,"end":1293820,"confidence":0.99625653,"speaker":"A"},{"text":"have","start":1293820,"end":1293980,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1293980,"end":1294100,"confidence":1,"speaker":"A"},{"text":"go","start":1294100,"end":1294220,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":1294220,"end":1294500,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1294660,"end":1294940,"confidence":0.99853516,"speaker":"A"},{"text":"I'd","start":1294940,"end":1295180,"confidence":0.8806966,"speaker":"A"},{"text":"have","start":1295180,"end":1295300,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1295300,"end":1295420,"confidence":0.9995117,"speaker":"A"},{"text":"figure","start":1295420,"end":1295660,"confidence":0.7961426,"speaker":"A"},{"text":"out","start":1295660,"end":1295820,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1295820,"end":1295980,"confidence":0.9970703,"speaker":"A"},{"text":"way","start":1295980,"end":1296260,"confidence":0.99560547,"speaker":"A"},{"text":"to","start":1296900,"end":1297020,"confidence":0.9819336,"speaker":"A"},{"text":"convert","start":1297020,"end":1297300,"confidence":0.9992676,"speaker":"A"},{"text":"all","start":1297300,"end":1297540,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1297540,"end":1297740,"confidence":0.9975586,"speaker":"A"},{"text":"documentation","start":1297740,"end":1298500,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":1298660,"end":1299060,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":1299140,"end":1299420,"confidence":0.99853516,"speaker":"A"},{"text":"open","start":1299420,"end":1299700,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1299700,"end":1300340,"confidence":0.9458008,"speaker":"A"},{"text":"document.","start":1300420,"end":1301140,"confidence":0.9998779,"speaker":"A"}]},{"text":"I mean, can you guess what helped me to get build an open API document from all this documentation? Some of the tools, some AI tool. Yes. AI came and I'm like, holy crap. Like AI is really good at documenting your code, but it's also pretty darn good at taking documentation and building code.","start":1302420,"end":1326250,"confidence":0.5463867,"words":[{"text":"I","start":1302420,"end":1302700,"confidence":0.5463867,"speaker":"A"},{"text":"mean,","start":1302700,"end":1302860,"confidence":0.9926758,"speaker":"A"},{"text":"can","start":1302860,"end":1303020,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1303020,"end":1303180,"confidence":0.99902344,"speaker":"A"},{"text":"guess","start":1303180,"end":1303540,"confidence":0.99975586,"speaker":"A"},{"text":"what","start":1303940,"end":1304260,"confidence":0.9995117,"speaker":"A"},{"text":"helped","start":1304260,"end":1304620,"confidence":0.76538086,"speaker":"A"},{"text":"me","start":1304620,"end":1304980,"confidence":0.9926758,"speaker":"A"},{"text":"to","start":1305540,"end":1305820,"confidence":0.9873047,"speaker":"A"},{"text":"get","start":1305820,"end":1306100,"confidence":0.6230469,"speaker":"A"},{"text":"build","start":1306180,"end":1306580,"confidence":0.95996094,"speaker":"A"},{"text":"an","start":1306820,"end":1307100,"confidence":0.9550781,"speaker":"A"},{"text":"open","start":1307100,"end":1307340,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1307340,"end":1307860,"confidence":0.90722656,"speaker":"A"},{"text":"document","start":1307860,"end":1308260,"confidence":0.9959717,"speaker":"A"},{"text":"from","start":1308260,"end":1308460,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":1308460,"end":1308620,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1308620,"end":1308820,"confidence":0.9555664,"speaker":"A"},{"text":"documentation?","start":1308820,"end":1309540,"confidence":0.9988281,"speaker":"A"},{"text":"Some","start":1310340,"end":1310740,"confidence":0.62402344,"speaker":"B"},{"text":"of","start":1311060,"end":1311260,"confidence":0.25683594,"speaker":"B"},{"text":"the","start":1311260,"end":1311300,"confidence":0.56347656,"speaker":"B"},{"text":"tools,","start":1311300,"end":1311620,"confidence":0.72314453,"speaker":"B"},{"text":"some","start":1312659,"end":1312940,"confidence":0.9658203,"speaker":"B"},{"text":"AI","start":1312940,"end":1313260,"confidence":0.9914551,"speaker":"B"},{"text":"tool.","start":1313260,"end":1313540,"confidence":0.9716797,"speaker":"B"},{"text":"Yes.","start":1314500,"end":1314980,"confidence":0.9482422,"speaker":"A"},{"text":"AI","start":1316820,"end":1317340,"confidence":0.91967773,"speaker":"A"},{"text":"came","start":1317340,"end":1317620,"confidence":0.9980469,"speaker":"A"},{"text":"and","start":1317620,"end":1317900,"confidence":0.99853516,"speaker":"A"},{"text":"I'm","start":1317900,"end":1318140,"confidence":0.99934894,"speaker":"A"},{"text":"like,","start":1318140,"end":1318340,"confidence":0.9921875,"speaker":"A"},{"text":"holy","start":1318340,"end":1318620,"confidence":0.82543945,"speaker":"A"},{"text":"crap.","start":1318620,"end":1318980,"confidence":0.86450195,"speaker":"A"},{"text":"Like","start":1319460,"end":1319860,"confidence":0.6220703,"speaker":"A"},{"text":"AI","start":1320180,"end":1320660,"confidence":0.92407227,"speaker":"A"},{"text":"is","start":1320660,"end":1320860,"confidence":0.9946289,"speaker":"A"},{"text":"really","start":1320860,"end":1321020,"confidence":0.99902344,"speaker":"A"},{"text":"good","start":1321020,"end":1321180,"confidence":0.99902344,"speaker":"A"},{"text":"at","start":1321180,"end":1321340,"confidence":0.9995117,"speaker":"A"},{"text":"documenting","start":1321340,"end":1321820,"confidence":0.99990237,"speaker":"A"},{"text":"your","start":1321820,"end":1321980,"confidence":0.99902344,"speaker":"A"},{"text":"code,","start":1321980,"end":1322260,"confidence":0.9998372,"speaker":"A"},{"text":"but","start":1322260,"end":1322460,"confidence":0.96972656,"speaker":"A"},{"text":"it's","start":1322460,"end":1322660,"confidence":0.9749349,"speaker":"A"},{"text":"also","start":1322660,"end":1322820,"confidence":0.9995117,"speaker":"A"},{"text":"pretty","start":1322820,"end":1323060,"confidence":0.9996745,"speaker":"A"},{"text":"darn","start":1323060,"end":1323260,"confidence":0.90804034,"speaker":"A"},{"text":"good","start":1323260,"end":1323420,"confidence":1,"speaker":"A"},{"text":"at","start":1323420,"end":1323700,"confidence":0.9902344,"speaker":"A"},{"text":"taking","start":1324490,"end":1324690,"confidence":0.93066406,"speaker":"A"},{"text":"documentation","start":1324690,"end":1325370,"confidence":0.9998047,"speaker":"A"},{"text":"and","start":1325370,"end":1325570,"confidence":0.99609375,"speaker":"A"},{"text":"building","start":1325570,"end":1325810,"confidence":0.9995117,"speaker":"A"},{"text":"code.","start":1325810,"end":1326250,"confidence":0.8733724,"speaker":"A"}]},{"text":"So then I would just plug it. I've been plugging in with Claude and it has a copy of all the documentation in my repo and it can go ahead and edit the open API. It's not perfect by any means, of course, but that's what unit tests are for.","start":1326890,"end":1341610,"confidence":0.9238281,"words":[{"text":"So","start":1326890,"end":1327170,"confidence":0.9238281,"speaker":"A"},{"text":"then","start":1327170,"end":1327450,"confidence":0.99658203,"speaker":"A"},{"text":"I","start":1327930,"end":1328250,"confidence":0.9819336,"speaker":"A"},{"text":"would","start":1328250,"end":1328450,"confidence":0.9848633,"speaker":"A"},{"text":"just","start":1328450,"end":1328610,"confidence":0.99902344,"speaker":"A"},{"text":"plug","start":1328610,"end":1328850,"confidence":0.9938965,"speaker":"A"},{"text":"it.","start":1328850,"end":1329050,"confidence":0.8227539,"speaker":"A"},{"text":"I've","start":1329050,"end":1329290,"confidence":0.99397784,"speaker":"A"},{"text":"been","start":1329290,"end":1329410,"confidence":0.9975586,"speaker":"A"},{"text":"plugging","start":1329410,"end":1329730,"confidence":0.95751953,"speaker":"A"},{"text":"in","start":1329730,"end":1329890,"confidence":0.8691406,"speaker":"A"},{"text":"with","start":1329890,"end":1330050,"confidence":0.9995117,"speaker":"A"},{"text":"Claude","start":1330050,"end":1330650,"confidence":0.73999023,"speaker":"A"},{"text":"and","start":1331050,"end":1331330,"confidence":0.9667969,"speaker":"A"},{"text":"it","start":1331330,"end":1331490,"confidence":0.9975586,"speaker":"A"},{"text":"has","start":1331490,"end":1331650,"confidence":1,"speaker":"A"},{"text":"a","start":1331650,"end":1331850,"confidence":0.9995117,"speaker":"A"},{"text":"copy","start":1331850,"end":1332170,"confidence":1,"speaker":"A"},{"text":"of","start":1332170,"end":1332290,"confidence":1,"speaker":"A"},{"text":"all","start":1332290,"end":1332450,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1332450,"end":1332610,"confidence":0.9995117,"speaker":"A"},{"text":"documentation","start":1332610,"end":1333210,"confidence":0.99970704,"speaker":"A"},{"text":"in","start":1333210,"end":1333410,"confidence":0.9277344,"speaker":"A"},{"text":"my","start":1333410,"end":1333570,"confidence":1,"speaker":"A"},{"text":"repo","start":1333570,"end":1334090,"confidence":0.9848633,"speaker":"A"},{"text":"and","start":1334410,"end":1334730,"confidence":0.9682617,"speaker":"A"},{"text":"it","start":1334730,"end":1334930,"confidence":0.8828125,"speaker":"A"},{"text":"can","start":1334930,"end":1335090,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1335090,"end":1335250,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":1335250,"end":1335410,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1335410,"end":1335610,"confidence":0.99853516,"speaker":"A"},{"text":"edit","start":1335610,"end":1336090,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1336250,"end":1336490,"confidence":0.9824219,"speaker":"A"},{"text":"open","start":1336490,"end":1336690,"confidence":0.99316406,"speaker":"A"},{"text":"API.","start":1336690,"end":1337210,"confidence":0.9802246,"speaker":"A"},{"text":"It's","start":1337210,"end":1337490,"confidence":0.9817708,"speaker":"A"},{"text":"not","start":1337490,"end":1337690,"confidence":0.99853516,"speaker":"A"},{"text":"perfect","start":1337690,"end":1338010,"confidence":0.97998047,"speaker":"A"},{"text":"by","start":1338010,"end":1338250,"confidence":0.99853516,"speaker":"A"},{"text":"any","start":1338250,"end":1338490,"confidence":1,"speaker":"A"},{"text":"means,","start":1338490,"end":1338810,"confidence":1,"speaker":"A"},{"text":"of","start":1338810,"end":1339090,"confidence":0.99902344,"speaker":"A"},{"text":"course,","start":1339090,"end":1339370,"confidence":1,"speaker":"A"},{"text":"but","start":1339530,"end":1339849,"confidence":0.9970703,"speaker":"A"},{"text":"that's","start":1339849,"end":1340170,"confidence":0.9998372,"speaker":"A"},{"text":"what","start":1340170,"end":1340410,"confidence":0.9980469,"speaker":"A"},{"text":"unit","start":1340410,"end":1340850,"confidence":0.84521484,"speaker":"A"},{"text":"tests","start":1340850,"end":1341210,"confidence":0.9946289,"speaker":"A"},{"text":"are","start":1341210,"end":1341330,"confidence":0.99560547,"speaker":"A"},{"text":"for.","start":1341330,"end":1341610,"confidence":0.99658203,"speaker":"A"}]},{"text":"And actually having integration tests in order to do stuff so that.","start":1343850,"end":1351700,"confidence":0.89697266,"words":[{"text":"And","start":1343850,"end":1344170,"confidence":0.89697266,"speaker":"A"},{"text":"actually","start":1344170,"end":1344410,"confidence":0.99853516,"speaker":"A"},{"text":"having","start":1344410,"end":1344650,"confidence":0.87402344,"speaker":"A"},{"text":"integration","start":1344650,"end":1345210,"confidence":0.9769287,"speaker":"A"},{"text":"tests","start":1345210,"end":1345770,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":1346250,"end":1346530,"confidence":0.99853516,"speaker":"A"},{"text":"order","start":1346530,"end":1346730,"confidence":1,"speaker":"A"},{"text":"to","start":1346730,"end":1346930,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1346930,"end":1347130,"confidence":0.9995117,"speaker":"A"},{"text":"stuff","start":1347130,"end":1347530,"confidence":0.9998372,"speaker":"A"},{"text":"so","start":1347690,"end":1348090,"confidence":0.83496094,"speaker":"A"},{"text":"that.","start":1351460,"end":1351700,"confidence":0.9980469,"speaker":"A"}]},{"text":"Sorry, I just want to make sure nothing important.","start":1355380,"end":1361460,"confidence":0.9995117,"words":[{"text":"Sorry,","start":1355380,"end":1355740,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1355740,"end":1355860,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1355860,"end":1355980,"confidence":1,"speaker":"A"},{"text":"want","start":1355980,"end":1356140,"confidence":0.99560547,"speaker":"A"},{"text":"to","start":1356140,"end":1356300,"confidence":0.99365234,"speaker":"A"},{"text":"make","start":1356300,"end":1356460,"confidence":1,"speaker":"A"},{"text":"sure","start":1356460,"end":1356740,"confidence":1,"speaker":"A"},{"text":"nothing","start":1360660,"end":1361100,"confidence":0.88623047,"speaker":"A"},{"text":"important.","start":1361100,"end":1361460,"confidence":1,"speaker":"A"}]},{"text":"I hate teams.","start":1366900,"end":1368020,"confidence":0.9951172,"words":[{"text":"I","start":1366900,"end":1367180,"confidence":0.9951172,"speaker":"A"},{"text":"hate","start":1367180,"end":1367460,"confidence":0.9992676,"speaker":"A"},{"text":"teams.","start":1367460,"end":1368020,"confidence":0.9995117,"speaker":"A"}]},{"text":"Okay, so great. So let's talk about.","start":1373060,"end":1376420,"confidence":0.94677734,"words":[{"text":"Okay,","start":1373060,"end":1373620,"confidence":0.94677734,"speaker":"A"},{"text":"so","start":1374820,"end":1375100,"confidence":0.9980469,"speaker":"A"},{"text":"great.","start":1375100,"end":1375380,"confidence":0.9980469,"speaker":"A"},{"text":"So","start":1375700,"end":1375780,"confidence":0.9995117,"speaker":"A"},{"text":"let's","start":1375780,"end":1375980,"confidence":0.9996745,"speaker":"A"},{"text":"talk","start":1375980,"end":1376140,"confidence":0.9995117,"speaker":"A"},{"text":"about.","start":1376140,"end":1376420,"confidence":0.9980469,"speaker":"A"}]},{"text":"Sorry, slides are still not done, but let's talk about authentication methods. You can see I have the logos here, but I haven't quite cleaned this up. So there's really two and a half authentication methods when it comes to CloudKit. So here is the miss demo database. You just go in here and you can go to tokens and keys and then that will give you access to set up either the API if you want to do API key or API token if you want to do a private database or a server to server keyset if you want to do a public database.","start":1379700,"end":1420190,"confidence":0.90966797,"words":[{"text":"Sorry,","start":1379700,"end":1380180,"confidence":0.90966797,"speaker":"A"},{"text":"slides","start":1380500,"end":1380900,"confidence":0.76538086,"speaker":"A"},{"text":"are","start":1380900,"end":1381100,"confidence":0.9995117,"speaker":"A"},{"text":"still","start":1381100,"end":1381260,"confidence":1,"speaker":"A"},{"text":"not","start":1381260,"end":1381420,"confidence":1,"speaker":"A"},{"text":"done,","start":1381420,"end":1381620,"confidence":0.9980469,"speaker":"A"},{"text":"but","start":1381620,"end":1381940,"confidence":0.99316406,"speaker":"A"},{"text":"let's","start":1382100,"end":1382460,"confidence":0.9991862,"speaker":"A"},{"text":"talk","start":1382460,"end":1382620,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":1382620,"end":1382900,"confidence":0.9980469,"speaker":"A"},{"text":"authentication","start":1384500,"end":1385380,"confidence":1,"speaker":"A"},{"text":"methods.","start":1385380,"end":1386020,"confidence":0.99975586,"speaker":"A"},{"text":"You","start":1386340,"end":1386620,"confidence":0.9970703,"speaker":"A"},{"text":"can","start":1386620,"end":1386780,"confidence":0.8959961,"speaker":"A"},{"text":"see","start":1386780,"end":1386940,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1386940,"end":1387100,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":1387100,"end":1387380,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1387460,"end":1387740,"confidence":0.99121094,"speaker":"A"},{"text":"logos","start":1387740,"end":1388140,"confidence":0.9980469,"speaker":"A"},{"text":"here,","start":1388140,"end":1388300,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":1388300,"end":1388420,"confidence":1,"speaker":"A"},{"text":"I","start":1388420,"end":1388540,"confidence":0.9995117,"speaker":"A"},{"text":"haven't","start":1388540,"end":1388780,"confidence":0.99975586,"speaker":"A"},{"text":"quite","start":1388780,"end":1389020,"confidence":0.99975586,"speaker":"A"},{"text":"cleaned","start":1389020,"end":1389340,"confidence":0.79541016,"speaker":"A"},{"text":"this","start":1389340,"end":1389540,"confidence":0.9941406,"speaker":"A"},{"text":"up.","start":1389540,"end":1389860,"confidence":0.9970703,"speaker":"A"},{"text":"So","start":1390820,"end":1391220,"confidence":0.9770508,"speaker":"A"},{"text":"there's","start":1391940,"end":1392540,"confidence":0.9983724,"speaker":"A"},{"text":"really","start":1392540,"end":1392900,"confidence":0.99902344,"speaker":"A"},{"text":"two","start":1393780,"end":1394140,"confidence":1,"speaker":"A"},{"text":"and","start":1394140,"end":1394380,"confidence":0.87890625,"speaker":"A"},{"text":"a","start":1394380,"end":1394540,"confidence":0.9667969,"speaker":"A"},{"text":"half","start":1394540,"end":1394820,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":1394820,"end":1395660,"confidence":0.99975586,"speaker":"A"},{"text":"methods","start":1395660,"end":1396140,"confidence":1,"speaker":"A"},{"text":"when","start":1396140,"end":1396300,"confidence":1,"speaker":"A"},{"text":"it","start":1396300,"end":1396420,"confidence":1,"speaker":"A"},{"text":"comes","start":1396420,"end":1396540,"confidence":1,"speaker":"A"},{"text":"to","start":1396540,"end":1396700,"confidence":1,"speaker":"A"},{"text":"CloudKit.","start":1396700,"end":1397380,"confidence":0.9552,"speaker":"A"},{"text":"So","start":1398420,"end":1398820,"confidence":0.9326172,"speaker":"A"},{"text":"here","start":1398900,"end":1399300,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":1399460,"end":1399860,"confidence":0.9658203,"speaker":"A"},{"text":"the","start":1401150,"end":1401270,"confidence":0.95947266,"speaker":"A"},{"text":"miss","start":1401270,"end":1401470,"confidence":0.5654297,"speaker":"A"},{"text":"demo","start":1401470,"end":1401950,"confidence":0.7548828,"speaker":"A"},{"text":"database.","start":1401950,"end":1402630,"confidence":0.9996745,"speaker":"A"},{"text":"You","start":1402630,"end":1402870,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1402870,"end":1403030,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1403030,"end":1403230,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1403230,"end":1403430,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":1403430,"end":1403710,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1404270,"end":1404550,"confidence":0.99560547,"speaker":"A"},{"text":"you","start":1404550,"end":1404710,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1404710,"end":1404870,"confidence":0.99365234,"speaker":"A"},{"text":"go","start":1404870,"end":1404990,"confidence":1,"speaker":"A"},{"text":"to","start":1404990,"end":1405110,"confidence":0.9995117,"speaker":"A"},{"text":"tokens","start":1405110,"end":1405510,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":1405510,"end":1405670,"confidence":0.9892578,"speaker":"A"},{"text":"keys","start":1405670,"end":1406070,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":1406070,"end":1406310,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":1406310,"end":1406470,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1406470,"end":1406630,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1406630,"end":1406790,"confidence":0.9995117,"speaker":"A"},{"text":"give","start":1406790,"end":1406950,"confidence":1,"speaker":"A"},{"text":"you","start":1406950,"end":1407150,"confidence":1,"speaker":"A"},{"text":"access","start":1407150,"end":1407470,"confidence":1,"speaker":"A"},{"text":"to","start":1407470,"end":1407750,"confidence":0.98339844,"speaker":"A"},{"text":"set","start":1407750,"end":1407950,"confidence":0.99658203,"speaker":"A"},{"text":"up","start":1407950,"end":1408270,"confidence":0.7631836,"speaker":"A"},{"text":"either","start":1408510,"end":1408990,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1408990,"end":1409390,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1409870,"end":1410550,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1410550,"end":1410750,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1410750,"end":1410870,"confidence":0.9243164,"speaker":"A"},{"text":"want","start":1410870,"end":1411030,"confidence":0.94921875,"speaker":"A"},{"text":"to","start":1411030,"end":1411150,"confidence":0.9980469,"speaker":"A"},{"text":"do","start":1411150,"end":1411390,"confidence":0.9970703,"speaker":"A"},{"text":"API","start":1411790,"end":1412430,"confidence":0.9926758,"speaker":"A"},{"text":"key","start":1412430,"end":1412830,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1412830,"end":1413110,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1413110,"end":1413470,"confidence":0.8027344,"speaker":"A"},{"text":"token","start":1413470,"end":1414030,"confidence":0.86376953,"speaker":"A"},{"text":"if","start":1414270,"end":1414550,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1414550,"end":1414710,"confidence":1,"speaker":"A"},{"text":"want","start":1414710,"end":1414830,"confidence":0.9394531,"speaker":"A"},{"text":"to","start":1414830,"end":1414910,"confidence":0.99902344,"speaker":"A"},{"text":"do","start":1414910,"end":1415070,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1415070,"end":1415270,"confidence":0.53125,"speaker":"A"},{"text":"private","start":1415270,"end":1415470,"confidence":1,"speaker":"A"},{"text":"database","start":1415470,"end":1416190,"confidence":0.9998372,"speaker":"A"},{"text":"or","start":1416190,"end":1416550,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1416550,"end":1416790,"confidence":0.99853516,"speaker":"A"},{"text":"server","start":1416790,"end":1417109,"confidence":0.9946289,"speaker":"A"},{"text":"to","start":1417109,"end":1417310,"confidence":0.97753906,"speaker":"A"},{"text":"server","start":1417310,"end":1417630,"confidence":0.9992676,"speaker":"A"},{"text":"keyset","start":1417630,"end":1418190,"confidence":0.8388672,"speaker":"A"},{"text":"if","start":1418350,"end":1418630,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1418630,"end":1418750,"confidence":0.99902344,"speaker":"A"},{"text":"want","start":1418750,"end":1418870,"confidence":0.53808594,"speaker":"A"},{"text":"to","start":1418870,"end":1418990,"confidence":0.9951172,"speaker":"A"},{"text":"do","start":1418990,"end":1419150,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1419150,"end":1419310,"confidence":0.8515625,"speaker":"A"},{"text":"public","start":1419310,"end":1419470,"confidence":1,"speaker":"A"},{"text":"database.","start":1419470,"end":1420190,"confidence":0.9996745,"speaker":"A"}]},{"text":"So let's talk about the API token. Pretty simple. You just go into here, click the plus sign, you say a name and you say whether you want to do a post message or URL redirect. We'll get into that in a little bit in the next section. And then whether you want to have user info and you click save and you'll get a nice little API token you could use in your web your web calls essentially.","start":1420190,"end":1446680,"confidence":0.98095703,"words":[{"text":"So","start":1420190,"end":1420430,"confidence":0.98095703,"speaker":"A"},{"text":"let's","start":1420430,"end":1420590,"confidence":0.9998372,"speaker":"A"},{"text":"talk","start":1420590,"end":1420710,"confidence":0.99902344,"speaker":"A"},{"text":"about","start":1420710,"end":1420870,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":1420870,"end":1421030,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1421030,"end":1421430,"confidence":0.99902344,"speaker":"A"},{"text":"token.","start":1421430,"end":1421950,"confidence":0.9773763,"speaker":"A"},{"text":"Pretty","start":1422510,"end":1422870,"confidence":1,"speaker":"A"},{"text":"simple.","start":1422870,"end":1423310,"confidence":0.83935547,"speaker":"A"},{"text":"You","start":1423470,"end":1423750,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1423750,"end":1423870,"confidence":1,"speaker":"A"},{"text":"go","start":1423870,"end":1423990,"confidence":0.99609375,"speaker":"A"},{"text":"into","start":1423990,"end":1424190,"confidence":0.61572266,"speaker":"A"},{"text":"here,","start":1424190,"end":1424510,"confidence":0.9995117,"speaker":"A"},{"text":"click","start":1424750,"end":1425110,"confidence":0.9987793,"speaker":"A"},{"text":"the","start":1425110,"end":1425270,"confidence":0.9995117,"speaker":"A"},{"text":"plus","start":1425270,"end":1425550,"confidence":0.9980469,"speaker":"A"},{"text":"sign,","start":1425550,"end":1425870,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1426840,"end":1427000,"confidence":0.9980469,"speaker":"A"},{"text":"say","start":1427000,"end":1427200,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1427200,"end":1427320,"confidence":0.91064453,"speaker":"A"},{"text":"name","start":1427320,"end":1427560,"confidence":0.99609375,"speaker":"A"},{"text":"and","start":1428600,"end":1428920,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":1428920,"end":1429120,"confidence":0.99902344,"speaker":"A"},{"text":"say","start":1429120,"end":1429280,"confidence":0.9980469,"speaker":"A"},{"text":"whether","start":1429280,"end":1429440,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1429440,"end":1429600,"confidence":1,"speaker":"A"},{"text":"want","start":1429600,"end":1429720,"confidence":0.99560547,"speaker":"A"},{"text":"to","start":1429720,"end":1429800,"confidence":0.99560547,"speaker":"A"},{"text":"do","start":1429800,"end":1429920,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1429920,"end":1430040,"confidence":0.9995117,"speaker":"A"},{"text":"post","start":1430040,"end":1430240,"confidence":0.9995117,"speaker":"A"},{"text":"message","start":1430240,"end":1430680,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":1430680,"end":1430920,"confidence":0.9995117,"speaker":"A"},{"text":"URL","start":1430920,"end":1431440,"confidence":0.8330078,"speaker":"A"},{"text":"redirect.","start":1431440,"end":1432040,"confidence":1,"speaker":"A"},{"text":"We'll","start":1432280,"end":1432640,"confidence":0.9708659,"speaker":"A"},{"text":"get","start":1432640,"end":1432800,"confidence":1,"speaker":"A"},{"text":"into","start":1432800,"end":1432960,"confidence":1,"speaker":"A"},{"text":"that","start":1432960,"end":1433120,"confidence":1,"speaker":"A"},{"text":"in","start":1433120,"end":1433280,"confidence":0.8725586,"speaker":"A"},{"text":"a","start":1433280,"end":1433400,"confidence":0.99902344,"speaker":"A"},{"text":"little","start":1433400,"end":1433560,"confidence":0.9526367,"speaker":"A"},{"text":"bit","start":1433560,"end":1433760,"confidence":1,"speaker":"A"},{"text":"in","start":1433760,"end":1433920,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":1433920,"end":1434040,"confidence":0.9995117,"speaker":"A"},{"text":"next","start":1434040,"end":1434200,"confidence":0.9995117,"speaker":"A"},{"text":"section.","start":1434200,"end":1434680,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":1435960,"end":1436240,"confidence":0.98828125,"speaker":"A"},{"text":"then","start":1436240,"end":1436480,"confidence":0.89453125,"speaker":"A"},{"text":"whether","start":1436480,"end":1436760,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1436760,"end":1436960,"confidence":1,"speaker":"A"},{"text":"want","start":1436960,"end":1437120,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1437120,"end":1437280,"confidence":1,"speaker":"A"},{"text":"have","start":1437280,"end":1437560,"confidence":1,"speaker":"A"},{"text":"user","start":1437800,"end":1438280,"confidence":0.99902344,"speaker":"A"},{"text":"info","start":1438280,"end":1438760,"confidence":1,"speaker":"A"},{"text":"and","start":1438840,"end":1439240,"confidence":0.99609375,"speaker":"A"},{"text":"you","start":1439400,"end":1439720,"confidence":0.99609375,"speaker":"A"},{"text":"click","start":1439720,"end":1440040,"confidence":0.9995117,"speaker":"A"},{"text":"save","start":1440040,"end":1440360,"confidence":0.9987793,"speaker":"A"},{"text":"and","start":1440360,"end":1440640,"confidence":0.9326172,"speaker":"A"},{"text":"you'll","start":1440640,"end":1440920,"confidence":0.99934894,"speaker":"A"},{"text":"get","start":1440920,"end":1441040,"confidence":1,"speaker":"A"},{"text":"a","start":1441040,"end":1441160,"confidence":0.9995117,"speaker":"A"},{"text":"nice","start":1441160,"end":1441400,"confidence":0.99975586,"speaker":"A"},{"text":"little","start":1441400,"end":1441680,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1441680,"end":1442280,"confidence":0.86499023,"speaker":"A"},{"text":"token","start":1442519,"end":1442960,"confidence":0.9996745,"speaker":"A"},{"text":"you","start":1442960,"end":1443120,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":1443120,"end":1443280,"confidence":0.9951172,"speaker":"A"},{"text":"use","start":1443280,"end":1443520,"confidence":1,"speaker":"A"},{"text":"in","start":1443520,"end":1443760,"confidence":0.99658203,"speaker":"A"},{"text":"your","start":1443760,"end":1444040,"confidence":0.9848633,"speaker":"A"},{"text":"web","start":1444120,"end":1444600,"confidence":0.99560547,"speaker":"A"},{"text":"your","start":1445240,"end":1445560,"confidence":0.9873047,"speaker":"A"},{"text":"web","start":1445560,"end":1445840,"confidence":0.9987793,"speaker":"A"},{"text":"calls","start":1445840,"end":1446160,"confidence":0.9831543,"speaker":"A"},{"text":"essentially.","start":1446160,"end":1446680,"confidence":0.9581299,"speaker":"A"}]},{"text":"API doesn't really. The API token doesn't really give you a lot of. But what it does give you is it gives you an entry to get a web authentication token for a user. So basically the way that works. So you'll notice here, when we were in this section, we have this piece here called Sign in Callback.","start":1449000,"end":1469610,"confidence":0.8713379,"words":[{"text":"API","start":1449000,"end":1449560,"confidence":0.8713379,"speaker":"A"},{"text":"doesn't","start":1449560,"end":1449800,"confidence":0.99886066,"speaker":"A"},{"text":"really.","start":1449800,"end":1450000,"confidence":0.9980469,"speaker":"A"},{"text":"The","start":1450000,"end":1450200,"confidence":0.88720703,"speaker":"A"},{"text":"API","start":1450200,"end":1450640,"confidence":0.954834,"speaker":"A"},{"text":"token","start":1450640,"end":1451000,"confidence":0.99934894,"speaker":"A"},{"text":"doesn't","start":1451000,"end":1451200,"confidence":0.9160156,"speaker":"A"},{"text":"really","start":1451200,"end":1451360,"confidence":0.9995117,"speaker":"A"},{"text":"give","start":1451360,"end":1451520,"confidence":1,"speaker":"A"},{"text":"you","start":1451520,"end":1451680,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1451680,"end":1451800,"confidence":0.99853516,"speaker":"A"},{"text":"lot","start":1451800,"end":1452040,"confidence":0.99560547,"speaker":"A"},{"text":"of.","start":1452100,"end":1452260,"confidence":0.515625,"speaker":"A"},{"text":"But","start":1452570,"end":1452690,"confidence":0.98535156,"speaker":"A"},{"text":"what","start":1452690,"end":1452850,"confidence":0.99658203,"speaker":"A"},{"text":"it","start":1452850,"end":1452970,"confidence":0.9902344,"speaker":"A"},{"text":"does","start":1452970,"end":1453130,"confidence":0.9980469,"speaker":"A"},{"text":"give","start":1453130,"end":1453290,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1453290,"end":1453410,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":1453410,"end":1453570,"confidence":0.98779297,"speaker":"A"},{"text":"it","start":1453570,"end":1453690,"confidence":0.9951172,"speaker":"A"},{"text":"gives","start":1453690,"end":1453890,"confidence":0.9733887,"speaker":"A"},{"text":"you","start":1453890,"end":1454010,"confidence":1,"speaker":"A"},{"text":"an","start":1454010,"end":1454170,"confidence":1,"speaker":"A"},{"text":"entry","start":1454170,"end":1454530,"confidence":0.99975586,"speaker":"A"},{"text":"to","start":1454530,"end":1454850,"confidence":1,"speaker":"A"},{"text":"get","start":1454850,"end":1455130,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1455130,"end":1455330,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1455330,"end":1455570,"confidence":1,"speaker":"A"},{"text":"authentication","start":1455570,"end":1456250,"confidence":0.8823242,"speaker":"A"},{"text":"token","start":1456250,"end":1456610,"confidence":0.9998372,"speaker":"A"},{"text":"for","start":1456610,"end":1456770,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1456770,"end":1456930,"confidence":0.48901367,"speaker":"A"},{"text":"user.","start":1456930,"end":1457450,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":1457850,"end":1458130,"confidence":0.99121094,"speaker":"A"},{"text":"basically","start":1458130,"end":1458570,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1458730,"end":1459010,"confidence":1,"speaker":"A"},{"text":"way","start":1459010,"end":1459210,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1459210,"end":1459450,"confidence":1,"speaker":"A"},{"text":"works.","start":1459450,"end":1459930,"confidence":0.99731445,"speaker":"A"},{"text":"So","start":1460970,"end":1461370,"confidence":0.9580078,"speaker":"A"},{"text":"you'll","start":1461450,"end":1461810,"confidence":0.93896484,"speaker":"A"},{"text":"notice","start":1461810,"end":1462170,"confidence":0.99975586,"speaker":"A"},{"text":"here,","start":1462170,"end":1462490,"confidence":0.99902344,"speaker":"A"},{"text":"when","start":1463050,"end":1463370,"confidence":0.9941406,"speaker":"A"},{"text":"we","start":1463370,"end":1463570,"confidence":0.9995117,"speaker":"A"},{"text":"were","start":1463570,"end":1463770,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1463770,"end":1463970,"confidence":1,"speaker":"A"},{"text":"this","start":1463970,"end":1464250,"confidence":0.9995117,"speaker":"A"},{"text":"section,","start":1464330,"end":1464890,"confidence":0.99975586,"speaker":"A"},{"text":"we","start":1467050,"end":1467330,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":1467330,"end":1467490,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1467490,"end":1467690,"confidence":1,"speaker":"A"},{"text":"piece","start":1467690,"end":1467970,"confidence":0.9998372,"speaker":"A"},{"text":"here","start":1467970,"end":1468250,"confidence":0.99902344,"speaker":"A"},{"text":"called","start":1468250,"end":1468569,"confidence":0.99902344,"speaker":"A"},{"text":"Sign","start":1468569,"end":1468770,"confidence":0.9926758,"speaker":"A"},{"text":"in","start":1468770,"end":1468970,"confidence":0.48339844,"speaker":"A"},{"text":"Callback.","start":1468970,"end":1469610,"confidence":0.9967448,"speaker":"A"}]},{"text":"So you can have either call a JavaScript, it's called a message event, it will call a Message event and a message event will have the metadata with the web authentication token of that user. Or you could do URL redirect where on authentication the user has a URL and then part of that URL is then having part of one of the query parameters and we'll get into that. We'll then have the web authentication token in the URL. So you put, basically you have your website, you add the JavaScript, you need to add the sign in with Apple. Oh, here's Josh.","start":1469770,"end":1508010,"confidence":0.9580078,"words":[{"text":"So","start":1469770,"end":1470170,"confidence":0.9580078,"speaker":"A"},{"text":"you","start":1470330,"end":1470650,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1470650,"end":1470930,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1470930,"end":1471250,"confidence":0.98291016,"speaker":"A"},{"text":"either","start":1471250,"end":1471690,"confidence":1,"speaker":"A"},{"text":"call","start":1471690,"end":1472010,"confidence":0.9741211,"speaker":"A"},{"text":"a","start":1472010,"end":1472210,"confidence":0.96875,"speaker":"A"},{"text":"JavaScript,","start":1472210,"end":1472970,"confidence":0.9967448,"speaker":"A"},{"text":"it's","start":1473370,"end":1473730,"confidence":0.99593097,"speaker":"A"},{"text":"called","start":1473730,"end":1473930,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1473930,"end":1474130,"confidence":0.9794922,"speaker":"A"},{"text":"message","start":1474130,"end":1474530,"confidence":0.9980469,"speaker":"A"},{"text":"event,","start":1474530,"end":1474810,"confidence":0.9897461,"speaker":"A"},{"text":"it","start":1475610,"end":1475890,"confidence":0.9941406,"speaker":"A"},{"text":"will","start":1475890,"end":1476090,"confidence":0.82177734,"speaker":"A"},{"text":"call","start":1476090,"end":1476330,"confidence":0.6923828,"speaker":"A"},{"text":"a","start":1476330,"end":1476530,"confidence":0.90625,"speaker":"A"},{"text":"Message","start":1476530,"end":1476850,"confidence":0.99902344,"speaker":"A"},{"text":"event","start":1476850,"end":1477090,"confidence":0.9897461,"speaker":"A"},{"text":"and","start":1477090,"end":1477450,"confidence":0.97265625,"speaker":"A"},{"text":"a","start":1477450,"end":1477730,"confidence":0.8847656,"speaker":"A"},{"text":"message","start":1477730,"end":1478050,"confidence":0.9987793,"speaker":"A"},{"text":"event","start":1478050,"end":1478250,"confidence":0.9951172,"speaker":"A"},{"text":"will","start":1478250,"end":1478450,"confidence":0.9921875,"speaker":"A"},{"text":"have","start":1478450,"end":1478610,"confidence":1,"speaker":"A"},{"text":"the","start":1478610,"end":1478730,"confidence":0.9975586,"speaker":"A"},{"text":"metadata","start":1478730,"end":1479250,"confidence":0.99886066,"speaker":"A"},{"text":"with","start":1479250,"end":1479410,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1479410,"end":1479530,"confidence":0.99560547,"speaker":"A"},{"text":"web","start":1479530,"end":1479730,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":1479730,"end":1480410,"confidence":0.99975586,"speaker":"A"},{"text":"token","start":1480410,"end":1480770,"confidence":0.9998372,"speaker":"A"},{"text":"of","start":1480770,"end":1480930,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1480930,"end":1481090,"confidence":0.99902344,"speaker":"A"},{"text":"user.","start":1481090,"end":1481530,"confidence":0.99902344,"speaker":"A"},{"text":"Or","start":1482410,"end":1482530,"confidence":0.9902344,"speaker":"A"},{"text":"you","start":1482530,"end":1482650,"confidence":0.7363281,"speaker":"A"},{"text":"could","start":1482650,"end":1482770,"confidence":0.99072266,"speaker":"A"},{"text":"do","start":1482770,"end":1482930,"confidence":0.9946289,"speaker":"A"},{"text":"URL","start":1482930,"end":1483450,"confidence":0.99658203,"speaker":"A"},{"text":"redirect","start":1483450,"end":1484090,"confidence":0.99975586,"speaker":"A"},{"text":"where","start":1484170,"end":1484570,"confidence":0.99121094,"speaker":"A"},{"text":"on","start":1484810,"end":1485210,"confidence":0.8457031,"speaker":"A"},{"text":"authentication","start":1485290,"end":1486050,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1486050,"end":1486290,"confidence":0.9975586,"speaker":"A"},{"text":"user","start":1486290,"end":1486730,"confidence":0.99975586,"speaker":"A"},{"text":"has","start":1486970,"end":1487250,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1487250,"end":1487410,"confidence":0.9975586,"speaker":"A"},{"text":"URL","start":1487410,"end":1487930,"confidence":0.998291,"speaker":"A"},{"text":"and","start":1487930,"end":1488130,"confidence":0.99609375,"speaker":"A"},{"text":"then","start":1488130,"end":1488290,"confidence":0.9560547,"speaker":"A"},{"text":"part","start":1488290,"end":1488450,"confidence":1,"speaker":"A"},{"text":"of","start":1488450,"end":1488570,"confidence":1,"speaker":"A"},{"text":"that","start":1488570,"end":1488690,"confidence":0.9995117,"speaker":"A"},{"text":"URL","start":1488690,"end":1489170,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":1489170,"end":1489330,"confidence":0.99609375,"speaker":"A"},{"text":"then","start":1489330,"end":1489530,"confidence":0.98291016,"speaker":"A"},{"text":"having","start":1489530,"end":1489850,"confidence":0.99658203,"speaker":"A"},{"text":"part","start":1490650,"end":1490930,"confidence":0.9921875,"speaker":"A"},{"text":"of","start":1490930,"end":1491090,"confidence":0.99853516,"speaker":"A"},{"text":"one","start":1491090,"end":1491210,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1491210,"end":1491290,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1491290,"end":1491370,"confidence":1,"speaker":"A"},{"text":"query","start":1491370,"end":1491690,"confidence":0.8486328,"speaker":"A"},{"text":"parameters","start":1491770,"end":1492570,"confidence":0.8824463,"speaker":"A"},{"text":"and","start":1492570,"end":1492850,"confidence":0.9814453,"speaker":"A"},{"text":"we'll","start":1492850,"end":1493050,"confidence":0.99934894,"speaker":"A"},{"text":"get","start":1493050,"end":1493130,"confidence":1,"speaker":"A"},{"text":"into","start":1493130,"end":1493290,"confidence":0.99902344,"speaker":"A"},{"text":"that.","start":1493290,"end":1493610,"confidence":0.9975586,"speaker":"A"},{"text":"We'll","start":1494250,"end":1494570,"confidence":0.89176434,"speaker":"A"},{"text":"then","start":1494570,"end":1494690,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":1494690,"end":1494850,"confidence":1,"speaker":"A"},{"text":"the","start":1494850,"end":1495010,"confidence":0.9980469,"speaker":"A"},{"text":"web","start":1495010,"end":1495250,"confidence":0.9904785,"speaker":"A"},{"text":"authentication","start":1495250,"end":1495810,"confidence":0.9975586,"speaker":"A"},{"text":"token","start":1495810,"end":1496130,"confidence":0.9996745,"speaker":"A"},{"text":"in","start":1496130,"end":1496290,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":1496290,"end":1496450,"confidence":1,"speaker":"A"},{"text":"URL.","start":1496450,"end":1497050,"confidence":0.99731445,"speaker":"A"},{"text":"So","start":1498570,"end":1498970,"confidence":0.9921875,"speaker":"A"},{"text":"you","start":1499050,"end":1499330,"confidence":0.9794922,"speaker":"A"},{"text":"put,","start":1499330,"end":1499610,"confidence":0.9970703,"speaker":"A"},{"text":"basically","start":1500010,"end":1500410,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1500410,"end":1500570,"confidence":0.71972656,"speaker":"A"},{"text":"have","start":1500570,"end":1500690,"confidence":0.99853516,"speaker":"A"},{"text":"your","start":1500690,"end":1500850,"confidence":1,"speaker":"A"},{"text":"website,","start":1500850,"end":1501130,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1501450,"end":1501850,"confidence":0.9995117,"speaker":"A"},{"text":"add","start":1501850,"end":1502130,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":1502130,"end":1502290,"confidence":0.9995117,"speaker":"A"},{"text":"JavaScript,","start":1502290,"end":1503050,"confidence":0.9950358,"speaker":"A"},{"text":"you","start":1503210,"end":1503490,"confidence":0.99658203,"speaker":"A"},{"text":"need","start":1503490,"end":1503770,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1504330,"end":1504730,"confidence":0.99902344,"speaker":"A"},{"text":"add","start":1504970,"end":1505330,"confidence":0.9892578,"speaker":"A"},{"text":"the","start":1505330,"end":1505570,"confidence":0.9975586,"speaker":"A"},{"text":"sign","start":1505570,"end":1505770,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1505770,"end":1505970,"confidence":0.99609375,"speaker":"A"},{"text":"with","start":1505970,"end":1506170,"confidence":1,"speaker":"A"},{"text":"Apple.","start":1506170,"end":1506650,"confidence":0.9987793,"speaker":"A"},{"text":"Oh,","start":1506970,"end":1507330,"confidence":0.8078613,"speaker":"A"},{"text":"here's","start":1507330,"end":1507650,"confidence":0.9991862,"speaker":"A"},{"text":"Josh.","start":1507650,"end":1508010,"confidence":0.9987793,"speaker":"A"}]},{"text":"Oh cool. Josh, you there?","start":1514310,"end":1515910,"confidence":0.9213867,"words":[{"text":"Oh","start":1514310,"end":1514510,"confidence":0.9213867,"speaker":"A"},{"text":"cool.","start":1514510,"end":1514870,"confidence":0.99902344,"speaker":"A"},{"text":"Josh,","start":1514870,"end":1515350,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1515350,"end":1515590,"confidence":0.97265625,"speaker":"A"},{"text":"there?","start":1515590,"end":1515910,"confidence":0.9995117,"speaker":"A"}]},{"text":"I hope so. Good. Okay. Hey, we were just talking about how to set up. I'm going to go back a little bit Evan, but not too far back.","start":1518790,"end":1526630,"confidence":0.99853516,"words":[{"text":"I","start":1518790,"end":1519110,"confidence":0.99853516,"speaker":"C"},{"text":"hope","start":1519110,"end":1519390,"confidence":1,"speaker":"C"},{"text":"so.","start":1519390,"end":1519750,"confidence":0.99902344,"speaker":"C"},{"text":"Good.","start":1520710,"end":1521070,"confidence":0.9868164,"speaker":"A"},{"text":"Okay.","start":1521070,"end":1521590,"confidence":0.97753906,"speaker":"A"},{"text":"Hey,","start":1521750,"end":1522110,"confidence":0.9992676,"speaker":"A"},{"text":"we","start":1522110,"end":1522230,"confidence":0.99902344,"speaker":"A"},{"text":"were","start":1522230,"end":1522350,"confidence":0.51660156,"speaker":"A"},{"text":"just","start":1522350,"end":1522510,"confidence":1,"speaker":"A"},{"text":"talking","start":1522510,"end":1522750,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":1522750,"end":1522990,"confidence":0.9970703,"speaker":"A"},{"text":"how","start":1522990,"end":1523230,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1523230,"end":1523430,"confidence":0.9902344,"speaker":"A"},{"text":"set","start":1523430,"end":1523630,"confidence":1,"speaker":"A"},{"text":"up.","start":1523630,"end":1523790,"confidence":0.984375,"speaker":"A"},{"text":"I'm","start":1523790,"end":1523990,"confidence":0.9970703,"speaker":"A"},{"text":"going","start":1523990,"end":1524070,"confidence":0.5854492,"speaker":"A"},{"text":"to","start":1524070,"end":1524150,"confidence":0.9951172,"speaker":"A"},{"text":"go","start":1524150,"end":1524269,"confidence":0.9975586,"speaker":"A"},{"text":"back","start":1524269,"end":1524429,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1524429,"end":1524550,"confidence":0.99902344,"speaker":"A"},{"text":"little","start":1524550,"end":1524630,"confidence":1,"speaker":"A"},{"text":"bit","start":1524630,"end":1524750,"confidence":0.99853516,"speaker":"A"},{"text":"Evan,","start":1524750,"end":1525190,"confidence":0.86279297,"speaker":"A"},{"text":"but","start":1525510,"end":1525790,"confidence":0.98535156,"speaker":"A"},{"text":"not","start":1525790,"end":1525950,"confidence":0.99316406,"speaker":"A"},{"text":"too","start":1525950,"end":1526110,"confidence":0.9980469,"speaker":"A"},{"text":"far","start":1526110,"end":1526310,"confidence":1,"speaker":"A"},{"text":"back.","start":1526310,"end":1526630,"confidence":0.99853516,"speaker":"A"}]},{"text":"Yeah, no worries. That's okay. But we talked about setting up API token and how to do that. So you go in here, you just click plus, you select your sign in callback and you put in a name and it'll give you an API token once you click save. Basically.","start":1527110,"end":1546310,"confidence":0.9895833,"words":[{"text":"Yeah,","start":1527110,"end":1527430,"confidence":0.9895833,"speaker":"B"},{"text":"no","start":1527430,"end":1527550,"confidence":0.9824219,"speaker":"B"},{"text":"worries.","start":1527550,"end":1527910,"confidence":0.998291,"speaker":"B"},{"text":"That's","start":1527990,"end":1528310,"confidence":0.99625653,"speaker":"A"},{"text":"okay.","start":1528310,"end":1528710,"confidence":0.9635417,"speaker":"A"},{"text":"But","start":1530470,"end":1530750,"confidence":0.9370117,"speaker":"A"},{"text":"we","start":1530750,"end":1530910,"confidence":0.9995117,"speaker":"A"},{"text":"talked","start":1530910,"end":1531110,"confidence":0.97265625,"speaker":"A"},{"text":"about","start":1531110,"end":1531270,"confidence":0.9980469,"speaker":"A"},{"text":"setting","start":1531270,"end":1531510,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":1531510,"end":1531750,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":1531830,"end":1532390,"confidence":0.9980469,"speaker":"A"},{"text":"token","start":1532390,"end":1532950,"confidence":1,"speaker":"A"},{"text":"and","start":1533270,"end":1533590,"confidence":0.9946289,"speaker":"A"},{"text":"how","start":1533590,"end":1533790,"confidence":1,"speaker":"A"},{"text":"to","start":1533790,"end":1533910,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1533910,"end":1534030,"confidence":1,"speaker":"A"},{"text":"that.","start":1534030,"end":1534310,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1535910,"end":1536150,"confidence":0.9707031,"speaker":"A"},{"text":"you","start":1536950,"end":1537350,"confidence":0.9169922,"speaker":"A"},{"text":"go","start":1537430,"end":1537710,"confidence":0.99072266,"speaker":"A"},{"text":"in","start":1537710,"end":1537870,"confidence":0.9941406,"speaker":"A"},{"text":"here,","start":1537870,"end":1538150,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1538150,"end":1538430,"confidence":0.9819336,"speaker":"A"},{"text":"just","start":1538430,"end":1538550,"confidence":0.9970703,"speaker":"A"},{"text":"click","start":1538550,"end":1538790,"confidence":0.9995117,"speaker":"A"},{"text":"plus,","start":1538790,"end":1539110,"confidence":0.9655762,"speaker":"A"},{"text":"you","start":1539110,"end":1539350,"confidence":0.9897461,"speaker":"A"},{"text":"select","start":1539350,"end":1539630,"confidence":0.9995117,"speaker":"A"},{"text":"your","start":1539630,"end":1539790,"confidence":0.9975586,"speaker":"A"},{"text":"sign","start":1539790,"end":1539990,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":1539990,"end":1540190,"confidence":0.9428711,"speaker":"A"},{"text":"callback","start":1540190,"end":1540710,"confidence":0.9742839,"speaker":"A"},{"text":"and","start":1540710,"end":1540950,"confidence":0.99365234,"speaker":"A"},{"text":"you","start":1540950,"end":1541150,"confidence":0.98828125,"speaker":"A"},{"text":"put","start":1541150,"end":1541310,"confidence":1,"speaker":"A"},{"text":"in","start":1541310,"end":1541470,"confidence":0.9379883,"speaker":"A"},{"text":"a","start":1541470,"end":1541670,"confidence":0.9404297,"speaker":"A"},{"text":"name","start":1541670,"end":1541990,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":1542630,"end":1542910,"confidence":0.90283203,"speaker":"A"},{"text":"it'll","start":1542910,"end":1543150,"confidence":0.84277344,"speaker":"A"},{"text":"give","start":1543150,"end":1543310,"confidence":1,"speaker":"A"},{"text":"you","start":1543310,"end":1543590,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":1543750,"end":1544030,"confidence":0.9770508,"speaker":"A"},{"text":"API","start":1544030,"end":1544470,"confidence":0.8105469,"speaker":"A"},{"text":"token","start":1544470,"end":1544950,"confidence":0.9941406,"speaker":"A"},{"text":"once","start":1544950,"end":1545150,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1545150,"end":1545310,"confidence":0.9995117,"speaker":"A"},{"text":"click","start":1545310,"end":1545550,"confidence":0.99975586,"speaker":"A"},{"text":"save.","start":1545550,"end":1545830,"confidence":0.9980469,"speaker":"A"},{"text":"Basically.","start":1545830,"end":1546310,"confidence":0.9953613,"speaker":"A"}]},{"text":"Come on.","start":1550549,"end":1551190,"confidence":0.9658203,"words":[{"text":"Come","start":1550549,"end":1550870,"confidence":0.9658203,"speaker":"A"},{"text":"on.","start":1550870,"end":1551190,"confidence":0.99853516,"speaker":"A"}]},{"text":"The reason you want an API token is this allows you to then have users Sign in to CloudKit either using, using the the web service like Curl or you could also do it through a website using CloudKit js. So web authentication token we talked about how you can either do the post message or you can do the URL redirect. Basically you have the JavaScript on your website and there has a button, click the button, you get this nice little window here sign in and then when you sign in if you had selected post message, you'll get the web authentication token and the data of the event in JavaScript or you will get the web authentication token as a URL in the callback URL here. Does that make sense?","start":1554470,"end":1607820,"confidence":0.9975586,"words":[{"text":"The","start":1554470,"end":1554710,"confidence":0.9975586,"speaker":"A"},{"text":"reason","start":1554710,"end":1554910,"confidence":1,"speaker":"A"},{"text":"you","start":1554910,"end":1555150,"confidence":0.84814453,"speaker":"A"},{"text":"want","start":1555150,"end":1555310,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":1555310,"end":1555470,"confidence":0.99658203,"speaker":"A"},{"text":"API","start":1555470,"end":1555830,"confidence":0.79589844,"speaker":"A"},{"text":"token","start":1555830,"end":1556190,"confidence":0.9998372,"speaker":"A"},{"text":"is","start":1556190,"end":1556390,"confidence":0.9941406,"speaker":"A"},{"text":"this","start":1556390,"end":1556590,"confidence":0.99902344,"speaker":"A"},{"text":"allows","start":1556590,"end":1556990,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":1556990,"end":1557190,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1557190,"end":1557390,"confidence":0.9946289,"speaker":"A"},{"text":"then","start":1557390,"end":1557670,"confidence":0.95654297,"speaker":"A"},{"text":"have","start":1558550,"end":1558830,"confidence":0.9995117,"speaker":"A"},{"text":"users","start":1558830,"end":1559350,"confidence":0.99886066,"speaker":"A"},{"text":"Sign","start":1559350,"end":1559670,"confidence":1,"speaker":"A"},{"text":"in","start":1559670,"end":1559990,"confidence":0.9448242,"speaker":"A"},{"text":"to","start":1559990,"end":1560390,"confidence":0.9980469,"speaker":"A"},{"text":"CloudKit","start":1560390,"end":1561190,"confidence":0.97046,"speaker":"A"},{"text":"either","start":1562820,"end":1563060,"confidence":0.99902344,"speaker":"A"},{"text":"using,","start":1563060,"end":1563380,"confidence":0.9873047,"speaker":"A"},{"text":"using","start":1565140,"end":1565500,"confidence":1,"speaker":"A"},{"text":"the","start":1565500,"end":1565860,"confidence":0.9794922,"speaker":"A"},{"text":"the","start":1566420,"end":1566700,"confidence":0.99853516,"speaker":"A"},{"text":"web","start":1566700,"end":1567060,"confidence":0.99975586,"speaker":"A"},{"text":"service","start":1567140,"end":1567540,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":1567620,"end":1567940,"confidence":0.9995117,"speaker":"A"},{"text":"Curl","start":1567940,"end":1568580,"confidence":0.8334961,"speaker":"A"},{"text":"or","start":1568900,"end":1569300,"confidence":1,"speaker":"A"},{"text":"you","start":1569300,"end":1569580,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":1569580,"end":1569820,"confidence":0.99609375,"speaker":"A"},{"text":"also","start":1569820,"end":1570140,"confidence":1,"speaker":"A"},{"text":"do","start":1570140,"end":1570380,"confidence":1,"speaker":"A"},{"text":"it","start":1570380,"end":1570540,"confidence":1,"speaker":"A"},{"text":"through","start":1570540,"end":1570700,"confidence":1,"speaker":"A"},{"text":"a","start":1570700,"end":1570860,"confidence":1,"speaker":"A"},{"text":"website","start":1570860,"end":1571100,"confidence":0.9995117,"speaker":"A"},{"text":"using","start":1571100,"end":1571380,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":1571380,"end":1571980,"confidence":0.998291,"speaker":"A"},{"text":"js.","start":1571980,"end":1572500,"confidence":0.83740234,"speaker":"A"},{"text":"So","start":1573780,"end":1574180,"confidence":0.99560547,"speaker":"A"},{"text":"web","start":1574420,"end":1574820,"confidence":0.97021484,"speaker":"A"},{"text":"authentication","start":1574820,"end":1575500,"confidence":0.9995117,"speaker":"A"},{"text":"token","start":1575500,"end":1576100,"confidence":0.9991862,"speaker":"A"},{"text":"we","start":1576100,"end":1576420,"confidence":0.9995117,"speaker":"A"},{"text":"talked","start":1576420,"end":1576700,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":1576700,"end":1576900,"confidence":0.99902344,"speaker":"A"},{"text":"how","start":1576900,"end":1577219,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1577219,"end":1577460,"confidence":1,"speaker":"A"},{"text":"can","start":1577460,"end":1577539,"confidence":1,"speaker":"A"},{"text":"either","start":1577539,"end":1577740,"confidence":1,"speaker":"A"},{"text":"do","start":1577740,"end":1577900,"confidence":1,"speaker":"A"},{"text":"the","start":1577900,"end":1578060,"confidence":1,"speaker":"A"},{"text":"post","start":1578060,"end":1578300,"confidence":1,"speaker":"A"},{"text":"message","start":1578300,"end":1578780,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1578780,"end":1578980,"confidence":0.8930664,"speaker":"A"},{"text":"you","start":1578980,"end":1579140,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":1579140,"end":1579260,"confidence":0.99853516,"speaker":"A"},{"text":"do","start":1579260,"end":1579380,"confidence":1,"speaker":"A"},{"text":"the","start":1579380,"end":1579500,"confidence":0.99853516,"speaker":"A"},{"text":"URL","start":1579500,"end":1579860,"confidence":0.77905273,"speaker":"A"},{"text":"redirect.","start":1579860,"end":1580420,"confidence":0.99975586,"speaker":"A"},{"text":"Basically","start":1581140,"end":1581700,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":1581700,"end":1582100,"confidence":1,"speaker":"A"},{"text":"have","start":1582100,"end":1582380,"confidence":1,"speaker":"A"},{"text":"the","start":1582380,"end":1582540,"confidence":0.99121094,"speaker":"A"},{"text":"JavaScript","start":1582540,"end":1583020,"confidence":0.9979655,"speaker":"A"},{"text":"on","start":1583020,"end":1583180,"confidence":1,"speaker":"A"},{"text":"your","start":1583180,"end":1583380,"confidence":1,"speaker":"A"},{"text":"website","start":1583380,"end":1583700,"confidence":0.9951172,"speaker":"A"},{"text":"and","start":1584820,"end":1585180,"confidence":0.9980469,"speaker":"A"},{"text":"there","start":1585180,"end":1585420,"confidence":0.58447266,"speaker":"A"},{"text":"has","start":1585420,"end":1585580,"confidence":0.8017578,"speaker":"A"},{"text":"a","start":1585580,"end":1585700,"confidence":1,"speaker":"A"},{"text":"button,","start":1585700,"end":1585980,"confidence":0.998291,"speaker":"A"},{"text":"click","start":1585980,"end":1586260,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1586260,"end":1586380,"confidence":0.9995117,"speaker":"A"},{"text":"button,","start":1586380,"end":1586620,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":1586620,"end":1586740,"confidence":0.99853516,"speaker":"A"},{"text":"get","start":1586740,"end":1586860,"confidence":0.99560547,"speaker":"A"},{"text":"this","start":1586860,"end":1587020,"confidence":0.9995117,"speaker":"A"},{"text":"nice","start":1587020,"end":1587260,"confidence":0.99975586,"speaker":"A"},{"text":"little","start":1587260,"end":1587460,"confidence":0.9995117,"speaker":"A"},{"text":"window","start":1587460,"end":1587820,"confidence":0.99975586,"speaker":"A"},{"text":"here","start":1587820,"end":1588100,"confidence":0.9951172,"speaker":"A"},{"text":"sign","start":1588780,"end":1588940,"confidence":0.95947266,"speaker":"A"},{"text":"in","start":1588940,"end":1589260,"confidence":0.99072266,"speaker":"A"},{"text":"and","start":1590860,"end":1591140,"confidence":0.9550781,"speaker":"A"},{"text":"then","start":1591140,"end":1591420,"confidence":0.9970703,"speaker":"A"},{"text":"when","start":1591820,"end":1592100,"confidence":1,"speaker":"A"},{"text":"you","start":1592100,"end":1592300,"confidence":0.9995117,"speaker":"A"},{"text":"sign","start":1592300,"end":1592540,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1592540,"end":1592820,"confidence":0.98583984,"speaker":"A"},{"text":"if","start":1592820,"end":1593060,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1593060,"end":1593340,"confidence":0.9995117,"speaker":"A"},{"text":"had","start":1593340,"end":1593660,"confidence":0.9121094,"speaker":"A"},{"text":"selected","start":1593660,"end":1594060,"confidence":0.9992676,"speaker":"A"},{"text":"post","start":1594060,"end":1594380,"confidence":0.9975586,"speaker":"A"},{"text":"message,","start":1594380,"end":1595020,"confidence":0.984375,"speaker":"A"},{"text":"you'll","start":1595340,"end":1595700,"confidence":0.9923503,"speaker":"A"},{"text":"get","start":1595700,"end":1595860,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1595860,"end":1596020,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1596020,"end":1596260,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":1596260,"end":1597020,"confidence":0.96813965,"speaker":"A"},{"text":"token","start":1597020,"end":1597540,"confidence":0.9998372,"speaker":"A"},{"text":"and","start":1597540,"end":1597820,"confidence":0.5283203,"speaker":"A"},{"text":"the","start":1597820,"end":1598020,"confidence":0.9995117,"speaker":"A"},{"text":"data","start":1598020,"end":1598260,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1598260,"end":1598500,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1598500,"end":1598660,"confidence":0.9995117,"speaker":"A"},{"text":"event","start":1598660,"end":1598940,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1598940,"end":1599260,"confidence":0.9291992,"speaker":"A"},{"text":"JavaScript","start":1599260,"end":1600060,"confidence":0.99348956,"speaker":"A"},{"text":"or","start":1600540,"end":1600900,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1600900,"end":1601140,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1601140,"end":1601300,"confidence":0.87109375,"speaker":"A"},{"text":"get","start":1601300,"end":1601460,"confidence":1,"speaker":"A"},{"text":"the","start":1601460,"end":1601580,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1601580,"end":1601780,"confidence":0.9980469,"speaker":"A"},{"text":"authentication","start":1601780,"end":1602460,"confidence":0.8979492,"speaker":"A"},{"text":"token","start":1602460,"end":1602860,"confidence":0.9996745,"speaker":"A"},{"text":"as","start":1602860,"end":1603060,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1603060,"end":1603220,"confidence":0.98779297,"speaker":"A"},{"text":"URL","start":1603220,"end":1603820,"confidence":0.86157227,"speaker":"A"},{"text":"in","start":1604300,"end":1604579,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1604579,"end":1604739,"confidence":1,"speaker":"A"},{"text":"callback","start":1604739,"end":1605260,"confidence":0.9983724,"speaker":"A"},{"text":"URL","start":1605260,"end":1605780,"confidence":0.8745117,"speaker":"A"},{"text":"here.","start":1605780,"end":1606140,"confidence":0.9975586,"speaker":"A"},{"text":"Does","start":1606780,"end":1607060,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1607060,"end":1607220,"confidence":0.9995117,"speaker":"A"},{"text":"make","start":1607220,"end":1607420,"confidence":0.9926758,"speaker":"A"},{"text":"sense?","start":1607420,"end":1607820,"confidence":0.9995117,"speaker":"A"}]},{"text":"Yep. Yeah. In some cases if you scour the Internet so Stack overflow will tell you and this has happened to me sometimes it will not be CK web authentication token, sometimes it'll be CK session because that's what Apple likes to do.","start":1610860,"end":1626600,"confidence":0.7561035,"words":[{"text":"Yep.","start":1610860,"end":1611420,"confidence":0.7561035,"speaker":"B"},{"text":"Yeah.","start":1612220,"end":1612860,"confidence":0.94124347,"speaker":"A"},{"text":"In","start":1613420,"end":1613740,"confidence":0.9975586,"speaker":"A"},{"text":"some","start":1613740,"end":1613940,"confidence":1,"speaker":"A"},{"text":"cases","start":1613940,"end":1614220,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1614380,"end":1614660,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1614660,"end":1614940,"confidence":1,"speaker":"A"},{"text":"scour","start":1615180,"end":1615620,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":1615620,"end":1615860,"confidence":0.9995117,"speaker":"A"},{"text":"Internet","start":1615860,"end":1616295,"confidence":0.99780273,"speaker":"A"},{"text":"so","start":1616295,"end":1616450,"confidence":0.37280273,"speaker":"A"},{"text":"Stack","start":1616520,"end":1616720,"confidence":0.94799805,"speaker":"A"},{"text":"overflow","start":1616720,"end":1617120,"confidence":0.9749756,"speaker":"A"},{"text":"will","start":1617120,"end":1617280,"confidence":0.9916992,"speaker":"A"},{"text":"tell","start":1617280,"end":1617440,"confidence":1,"speaker":"A"},{"text":"you","start":1617440,"end":1617600,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1617600,"end":1617800,"confidence":0.99658203,"speaker":"A"},{"text":"this","start":1617800,"end":1618000,"confidence":0.99902344,"speaker":"A"},{"text":"has","start":1618000,"end":1618200,"confidence":0.9765625,"speaker":"A"},{"text":"happened","start":1618200,"end":1618520,"confidence":0.99975586,"speaker":"A"},{"text":"to","start":1618520,"end":1618640,"confidence":0.9995117,"speaker":"A"},{"text":"me","start":1618640,"end":1618920,"confidence":0.9995117,"speaker":"A"},{"text":"sometimes","start":1619240,"end":1619720,"confidence":0.9998372,"speaker":"A"},{"text":"it","start":1619720,"end":1619800,"confidence":0.99902344,"speaker":"A"},{"text":"will","start":1619800,"end":1619920,"confidence":0.99853516,"speaker":"A"},{"text":"not","start":1619920,"end":1620080,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":1620080,"end":1620360,"confidence":0.99902344,"speaker":"A"},{"text":"CK","start":1620360,"end":1620920,"confidence":0.89404297,"speaker":"A"},{"text":"web","start":1620920,"end":1621200,"confidence":0.9916992,"speaker":"A"},{"text":"authentication","start":1621200,"end":1621880,"confidence":0.9996338,"speaker":"A"},{"text":"token,","start":1621880,"end":1622360,"confidence":0.9995117,"speaker":"A"},{"text":"sometimes","start":1622360,"end":1622760,"confidence":0.9954427,"speaker":"A"},{"text":"it'll","start":1622760,"end":1623000,"confidence":0.8121745,"speaker":"A"},{"text":"be","start":1623000,"end":1623080,"confidence":0.9995117,"speaker":"A"},{"text":"CK","start":1623080,"end":1623480,"confidence":0.8876953,"speaker":"A"},{"text":"session","start":1623480,"end":1624040,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":1624360,"end":1624760,"confidence":0.99853516,"speaker":"A"},{"text":"that's","start":1625240,"end":1625600,"confidence":0.9996745,"speaker":"A"},{"text":"what","start":1625600,"end":1625760,"confidence":0.99560547,"speaker":"A"},{"text":"Apple","start":1625760,"end":1626040,"confidence":0.99560547,"speaker":"A"},{"text":"likes","start":1626040,"end":1626280,"confidence":0.98999023,"speaker":"A"},{"text":"to","start":1626280,"end":1626360,"confidence":0.9995117,"speaker":"A"},{"text":"do.","start":1626360,"end":1626600,"confidence":0.9995117,"speaker":"A"}]},{"text":"But it's the same thing. So you basically want to look for either property or query parameter name and you should be good to go and then you'll have that user as well authentication token you could do. What I, what I've been doing is, is I've been take like making a call to a like local server for instance and then essentially then I could do whatever I want with that web authentication token. As long as you have the web authentication token and the API token you can do anything on a private database that the user has rights to. So you can go, you can go to town with that all this stuff gets Swift in a cookie too.","start":1629080,"end":1671420,"confidence":0.99316406,"words":[{"text":"But","start":1629080,"end":1629360,"confidence":0.99316406,"speaker":"A"},{"text":"it's","start":1629360,"end":1629560,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1629560,"end":1629680,"confidence":1,"speaker":"A"},{"text":"same","start":1629680,"end":1629840,"confidence":1,"speaker":"A"},{"text":"thing.","start":1629840,"end":1630120,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1630200,"end":1630480,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1630480,"end":1630640,"confidence":0.9980469,"speaker":"A"},{"text":"basically","start":1630640,"end":1630920,"confidence":0.99975586,"speaker":"A"},{"text":"want","start":1630920,"end":1631120,"confidence":0.8725586,"speaker":"A"},{"text":"to","start":1631120,"end":1631240,"confidence":1,"speaker":"A"},{"text":"look","start":1631240,"end":1631320,"confidence":1,"speaker":"A"},{"text":"for","start":1631320,"end":1631440,"confidence":1,"speaker":"A"},{"text":"either","start":1631440,"end":1631720,"confidence":0.99975586,"speaker":"A"},{"text":"property","start":1631720,"end":1632200,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":1632200,"end":1632520,"confidence":0.9995117,"speaker":"A"},{"text":"query","start":1632680,"end":1633160,"confidence":0.97436523,"speaker":"A"},{"text":"parameter","start":1633240,"end":1633840,"confidence":0.9998372,"speaker":"A"},{"text":"name","start":1633840,"end":1634160,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":1634160,"end":1634400,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1634400,"end":1634560,"confidence":0.9980469,"speaker":"A"},{"text":"should","start":1634560,"end":1634720,"confidence":1,"speaker":"A"},{"text":"be","start":1634720,"end":1634880,"confidence":1,"speaker":"A"},{"text":"good","start":1634880,"end":1635040,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1635040,"end":1635200,"confidence":0.9980469,"speaker":"A"},{"text":"go","start":1635200,"end":1635480,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":1636360,"end":1636640,"confidence":0.99560547,"speaker":"A"},{"text":"then","start":1636640,"end":1636760,"confidence":1,"speaker":"A"},{"text":"you'll","start":1636760,"end":1636960,"confidence":0.9902344,"speaker":"A"},{"text":"have","start":1636960,"end":1637080,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1637080,"end":1637160,"confidence":0.99902344,"speaker":"A"},{"text":"user","start":1637160,"end":1637400,"confidence":0.99902344,"speaker":"A"},{"text":"as","start":1637400,"end":1637520,"confidence":0.4970703,"speaker":"A"},{"text":"well","start":1637520,"end":1637800,"confidence":0.99316406,"speaker":"A"},{"text":"authentication","start":1637800,"end":1638520,"confidence":0.99902344,"speaker":"A"},{"text":"token","start":1638520,"end":1639080,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1639960,"end":1640240,"confidence":0.98876953,"speaker":"A"},{"text":"could","start":1640240,"end":1640400,"confidence":0.9658203,"speaker":"A"},{"text":"do.","start":1640400,"end":1640680,"confidence":0.9926758,"speaker":"A"},{"text":"What","start":1640920,"end":1641240,"confidence":0.9736328,"speaker":"A"},{"text":"I,","start":1641240,"end":1641560,"confidence":0.9926758,"speaker":"A"},{"text":"what","start":1641720,"end":1642000,"confidence":0.9086914,"speaker":"A"},{"text":"I've","start":1642000,"end":1642200,"confidence":0.99527997,"speaker":"A"},{"text":"been","start":1642200,"end":1642360,"confidence":0.9995117,"speaker":"A"},{"text":"doing","start":1642360,"end":1642680,"confidence":0.9995117,"speaker":"A"},{"text":"is,","start":1643490,"end":1643730,"confidence":0.9863281,"speaker":"A"},{"text":"is","start":1645170,"end":1645490,"confidence":0.94628906,"speaker":"A"},{"text":"I've","start":1645490,"end":1645850,"confidence":0.9996745,"speaker":"A"},{"text":"been","start":1645850,"end":1646130,"confidence":0.99853516,"speaker":"A"},{"text":"take","start":1647330,"end":1647730,"confidence":0.9165039,"speaker":"A"},{"text":"like","start":1647730,"end":1648050,"confidence":0.99902344,"speaker":"A"},{"text":"making","start":1648050,"end":1648290,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1648290,"end":1648490,"confidence":0.9995117,"speaker":"A"},{"text":"call","start":1648490,"end":1648690,"confidence":1,"speaker":"A"},{"text":"to","start":1648690,"end":1648930,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1648930,"end":1649130,"confidence":0.7597656,"speaker":"A"},{"text":"like","start":1649130,"end":1649370,"confidence":0.98779297,"speaker":"A"},{"text":"local","start":1649370,"end":1649690,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1649690,"end":1650170,"confidence":0.99975586,"speaker":"A"},{"text":"for","start":1650170,"end":1650330,"confidence":0.9995117,"speaker":"A"},{"text":"instance","start":1650330,"end":1650770,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":1651330,"end":1651650,"confidence":0.99853516,"speaker":"A"},{"text":"then","start":1651650,"end":1651970,"confidence":0.99902344,"speaker":"A"},{"text":"essentially","start":1651970,"end":1652690,"confidence":0.9987793,"speaker":"A"},{"text":"then","start":1653410,"end":1653690,"confidence":0.8886719,"speaker":"A"},{"text":"I","start":1653690,"end":1653810,"confidence":1,"speaker":"A"},{"text":"could","start":1653810,"end":1653930,"confidence":0.6508789,"speaker":"A"},{"text":"do","start":1653930,"end":1654090,"confidence":0.9995117,"speaker":"A"},{"text":"whatever","start":1654090,"end":1654330,"confidence":1,"speaker":"A"},{"text":"I","start":1654330,"end":1654490,"confidence":0.9995117,"speaker":"A"},{"text":"want","start":1654490,"end":1654690,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":1654690,"end":1654890,"confidence":0.99853516,"speaker":"A"},{"text":"that","start":1654890,"end":1655050,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1655050,"end":1655290,"confidence":0.9897461,"speaker":"A"},{"text":"authentication","start":1655290,"end":1655970,"confidence":0.9991455,"speaker":"A"},{"text":"token.","start":1655970,"end":1656330,"confidence":0.9996745,"speaker":"A"},{"text":"As","start":1656330,"end":1656490,"confidence":0.9995117,"speaker":"A"},{"text":"long","start":1656490,"end":1656610,"confidence":1,"speaker":"A"},{"text":"as","start":1656610,"end":1656690,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1656690,"end":1656770,"confidence":1,"speaker":"A"},{"text":"have","start":1656770,"end":1656890,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1656890,"end":1657010,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1657010,"end":1657210,"confidence":0.998291,"speaker":"A"},{"text":"authentication","start":1657210,"end":1657730,"confidence":0.99975586,"speaker":"A"},{"text":"token","start":1657730,"end":1658090,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":1658090,"end":1658210,"confidence":0.9355469,"speaker":"A"},{"text":"the","start":1658210,"end":1658330,"confidence":0.99853516,"speaker":"A"},{"text":"API","start":1658330,"end":1658770,"confidence":0.9987793,"speaker":"A"},{"text":"token","start":1658770,"end":1659329,"confidence":0.9996745,"speaker":"A"},{"text":"you","start":1659570,"end":1659850,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1659850,"end":1660010,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1660010,"end":1660170,"confidence":1,"speaker":"A"},{"text":"anything","start":1660170,"end":1660570,"confidence":0.99975586,"speaker":"A"},{"text":"on","start":1660570,"end":1660730,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1660730,"end":1660850,"confidence":0.99902344,"speaker":"A"},{"text":"private","start":1660850,"end":1661050,"confidence":1,"speaker":"A"},{"text":"database","start":1661050,"end":1661810,"confidence":0.99934894,"speaker":"A"},{"text":"that","start":1662530,"end":1662810,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":1662810,"end":1662930,"confidence":0.9995117,"speaker":"A"},{"text":"user","start":1662930,"end":1663210,"confidence":1,"speaker":"A"},{"text":"has","start":1663210,"end":1663410,"confidence":0.99902344,"speaker":"A"},{"text":"rights","start":1663410,"end":1663690,"confidence":0.9975586,"speaker":"A"},{"text":"to.","start":1663690,"end":1664050,"confidence":0.9824219,"speaker":"A"},{"text":"So","start":1664450,"end":1664850,"confidence":0.9941406,"speaker":"A"},{"text":"you","start":1665890,"end":1666170,"confidence":0.98876953,"speaker":"A"},{"text":"can","start":1666170,"end":1666330,"confidence":0.95703125,"speaker":"A"},{"text":"go,","start":1666330,"end":1666570,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1666570,"end":1666810,"confidence":0.99560547,"speaker":"A"},{"text":"can","start":1666810,"end":1666970,"confidence":0.5966797,"speaker":"A"},{"text":"go","start":1666970,"end":1667130,"confidence":1,"speaker":"A"},{"text":"to","start":1667130,"end":1667250,"confidence":0.9980469,"speaker":"A"},{"text":"town","start":1667250,"end":1667410,"confidence":0.99902344,"speaker":"A"},{"text":"with","start":1667410,"end":1667610,"confidence":0.99609375,"speaker":"A"},{"text":"that","start":1667610,"end":1667890,"confidence":0.9848633,"speaker":"A"},{"text":"all","start":1669420,"end":1669540,"confidence":0.99365234,"speaker":"A"},{"text":"this","start":1669540,"end":1669700,"confidence":0.8154297,"speaker":"A"},{"text":"stuff","start":1669700,"end":1669900,"confidence":1,"speaker":"A"},{"text":"gets","start":1669900,"end":1670060,"confidence":0.99487305,"speaker":"A"},{"text":"Swift","start":1670060,"end":1670260,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":1670260,"end":1670420,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1670420,"end":1670540,"confidence":0.9995117,"speaker":"A"},{"text":"cookie","start":1670540,"end":1671020,"confidence":1,"speaker":"A"},{"text":"too.","start":1671020,"end":1671420,"confidence":0.9838867,"speaker":"A"}]},{"text":"So that way it'll work. When you go back, if you have checked the box for allow, it's either a box or JavaScript method property that will say, hey, I want this to persist. It'll be Swift in a, in a cookie as well. So if you want to spelunk your cookies, you can see the web authentication token there. So that's actually the easier of the two.","start":1671580,"end":1693500,"confidence":0.99658203,"words":[{"text":"So","start":1671580,"end":1671820,"confidence":0.99658203,"speaker":"A"},{"text":"that","start":1671820,"end":1671940,"confidence":1,"speaker":"A"},{"text":"way","start":1671940,"end":1672180,"confidence":0.9995117,"speaker":"A"},{"text":"it'll","start":1672180,"end":1672540,"confidence":0.8470052,"speaker":"A"},{"text":"work.","start":1672540,"end":1672860,"confidence":1,"speaker":"A"},{"text":"When","start":1673740,"end":1674020,"confidence":1,"speaker":"A"},{"text":"you","start":1674020,"end":1674220,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1674220,"end":1674460,"confidence":1,"speaker":"A"},{"text":"back,","start":1674460,"end":1674700,"confidence":1,"speaker":"A"},{"text":"if","start":1674700,"end":1674940,"confidence":0.53125,"speaker":"A"},{"text":"you","start":1674940,"end":1675260,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1675500,"end":1675900,"confidence":0.9995117,"speaker":"A"},{"text":"checked","start":1675900,"end":1676420,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":1676420,"end":1676580,"confidence":1,"speaker":"A"},{"text":"box","start":1676580,"end":1676900,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":1676900,"end":1677180,"confidence":0.99902344,"speaker":"A"},{"text":"allow,","start":1677180,"end":1677500,"confidence":0.99560547,"speaker":"A"},{"text":"it's","start":1678780,"end":1679100,"confidence":0.9899089,"speaker":"A"},{"text":"either","start":1679100,"end":1679340,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":1679340,"end":1679540,"confidence":0.9995117,"speaker":"A"},{"text":"box","start":1679540,"end":1679780,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":1679780,"end":1679980,"confidence":0.99902344,"speaker":"A"},{"text":"JavaScript","start":1679980,"end":1680580,"confidence":0.99934894,"speaker":"A"},{"text":"method","start":1680580,"end":1680900,"confidence":0.99348956,"speaker":"A"},{"text":"property","start":1680900,"end":1681260,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1681260,"end":1681460,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1681460,"end":1681700,"confidence":0.9013672,"speaker":"A"},{"text":"say,","start":1681700,"end":1681940,"confidence":0.9975586,"speaker":"A"},{"text":"hey,","start":1681940,"end":1682180,"confidence":0.9992676,"speaker":"A"},{"text":"I","start":1682180,"end":1682300,"confidence":1,"speaker":"A"},{"text":"want","start":1682300,"end":1682420,"confidence":1,"speaker":"A"},{"text":"this","start":1682420,"end":1682580,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":1682580,"end":1682740,"confidence":1,"speaker":"A"},{"text":"persist.","start":1682740,"end":1683260,"confidence":0.9992676,"speaker":"A"},{"text":"It'll","start":1683420,"end":1683780,"confidence":0.9715169,"speaker":"A"},{"text":"be","start":1683780,"end":1683900,"confidence":1,"speaker":"A"},{"text":"Swift","start":1683900,"end":1684100,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":1684100,"end":1684260,"confidence":0.9121094,"speaker":"A"},{"text":"a,","start":1684260,"end":1684420,"confidence":0.7871094,"speaker":"A"},{"text":"in","start":1684420,"end":1684580,"confidence":0.71191406,"speaker":"A"},{"text":"a","start":1684580,"end":1684740,"confidence":0.9995117,"speaker":"A"},{"text":"cookie","start":1684740,"end":1685020,"confidence":0.99975586,"speaker":"A"},{"text":"as","start":1685020,"end":1685179,"confidence":1,"speaker":"A"},{"text":"well.","start":1685179,"end":1685460,"confidence":1,"speaker":"A"},{"text":"So","start":1685460,"end":1685700,"confidence":0.99658203,"speaker":"A"},{"text":"if","start":1685700,"end":1685820,"confidence":1,"speaker":"A"},{"text":"you","start":1685820,"end":1685940,"confidence":1,"speaker":"A"},{"text":"want","start":1685940,"end":1686060,"confidence":0.95751953,"speaker":"A"},{"text":"to","start":1686060,"end":1686220,"confidence":0.97314453,"speaker":"A"},{"text":"spelunk","start":1686220,"end":1686820,"confidence":0.9758301,"speaker":"A"},{"text":"your","start":1686820,"end":1686980,"confidence":0.99560547,"speaker":"A"},{"text":"cookies,","start":1686980,"end":1687260,"confidence":1,"speaker":"A"},{"text":"you","start":1687340,"end":1687580,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1687580,"end":1687820,"confidence":0.9995117,"speaker":"A"},{"text":"see","start":1687980,"end":1688300,"confidence":0.78027344,"speaker":"A"},{"text":"the","start":1688300,"end":1688500,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1688500,"end":1688740,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":1688740,"end":1689340,"confidence":0.99938965,"speaker":"A"},{"text":"token","start":1689340,"end":1689740,"confidence":0.99902344,"speaker":"A"},{"text":"there.","start":1689740,"end":1690060,"confidence":0.99560547,"speaker":"A"},{"text":"So","start":1691500,"end":1691780,"confidence":0.9921875,"speaker":"A"},{"text":"that's","start":1691780,"end":1692100,"confidence":0.9995117,"speaker":"A"},{"text":"actually","start":1692100,"end":1692300,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1692300,"end":1692540,"confidence":0.99609375,"speaker":"A"},{"text":"easier","start":1692540,"end":1692900,"confidence":0.99975586,"speaker":"A"},{"text":"of","start":1692900,"end":1693020,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1693020,"end":1693180,"confidence":0.99902344,"speaker":"A"},{"text":"two.","start":1693180,"end":1693500,"confidence":0.9926758,"speaker":"A"}]},{"text":"So that gives you the private database for the public database is where you're going to need a server to server authentication. And so to do that it's really actually not as bad as I thought it was going to be. But you go to the new server to server key, put in a name you want, it'll actually give you the command you need to run and then you just paste in the public key in here. That gives you. That will give you everything you need.","start":1694380,"end":1720300,"confidence":0.99902344,"words":[{"text":"So","start":1694380,"end":1694660,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1694660,"end":1694820,"confidence":1,"speaker":"A"},{"text":"gives","start":1694820,"end":1695020,"confidence":1,"speaker":"A"},{"text":"you","start":1695020,"end":1695100,"confidence":1,"speaker":"A"},{"text":"the","start":1695100,"end":1695220,"confidence":0.9995117,"speaker":"A"},{"text":"private","start":1695220,"end":1695420,"confidence":1,"speaker":"A"},{"text":"database","start":1695420,"end":1695940,"confidence":0.9998372,"speaker":"A"},{"text":"for","start":1695940,"end":1696100,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":1696100,"end":1696220,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1696220,"end":1696380,"confidence":1,"speaker":"A"},{"text":"database","start":1696380,"end":1696940,"confidence":0.99886066,"speaker":"A"},{"text":"is","start":1696940,"end":1697140,"confidence":0.98876953,"speaker":"A"},{"text":"where","start":1697140,"end":1697300,"confidence":0.99902344,"speaker":"A"},{"text":"you're","start":1697300,"end":1697500,"confidence":0.9975586,"speaker":"A"},{"text":"going","start":1697500,"end":1697580,"confidence":0.9355469,"speaker":"A"},{"text":"to","start":1697580,"end":1697660,"confidence":0.9980469,"speaker":"A"},{"text":"need","start":1697660,"end":1697820,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1697820,"end":1697990,"confidence":0.55908203,"speaker":"A"},{"text":"server","start":1698220,"end":1698460,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1698460,"end":1698620,"confidence":0.9536133,"speaker":"A"},{"text":"server","start":1698620,"end":1699020,"confidence":0.99902344,"speaker":"A"},{"text":"authentication.","start":1699020,"end":1699820,"confidence":0.99938965,"speaker":"A"},{"text":"And","start":1701340,"end":1701700,"confidence":0.98876953,"speaker":"A"},{"text":"so","start":1701700,"end":1701940,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1701940,"end":1702100,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1702100,"end":1702300,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1702300,"end":1702620,"confidence":0.9970703,"speaker":"A"},{"text":"it's","start":1703180,"end":1703540,"confidence":0.9996745,"speaker":"A"},{"text":"really","start":1703540,"end":1703820,"confidence":0.99853516,"speaker":"A"},{"text":"actually","start":1703820,"end":1704180,"confidence":0.99853516,"speaker":"A"},{"text":"not","start":1704180,"end":1704420,"confidence":1,"speaker":"A"},{"text":"as","start":1704420,"end":1704620,"confidence":0.99902344,"speaker":"A"},{"text":"bad","start":1704620,"end":1704820,"confidence":1,"speaker":"A"},{"text":"as","start":1704820,"end":1704980,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1704980,"end":1705140,"confidence":1,"speaker":"A"},{"text":"thought","start":1705140,"end":1705260,"confidence":1,"speaker":"A"},{"text":"it","start":1705260,"end":1705340,"confidence":0.9975586,"speaker":"A"},{"text":"was","start":1705340,"end":1705460,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":1705460,"end":1705580,"confidence":0.8984375,"speaker":"A"},{"text":"to","start":1705580,"end":1705660,"confidence":1,"speaker":"A"},{"text":"be.","start":1705660,"end":1705900,"confidence":1,"speaker":"A"},{"text":"But","start":1705900,"end":1706300,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":1706620,"end":1706940,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1706940,"end":1707220,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":1707220,"end":1707500,"confidence":1,"speaker":"A"},{"text":"the","start":1707500,"end":1707700,"confidence":0.9995117,"speaker":"A"},{"text":"new","start":1707700,"end":1707980,"confidence":0.9970703,"speaker":"A"},{"text":"server","start":1708220,"end":1708620,"confidence":0.99731445,"speaker":"A"},{"text":"to","start":1708620,"end":1708740,"confidence":0.8359375,"speaker":"A"},{"text":"server","start":1708740,"end":1709140,"confidence":0.99731445,"speaker":"A"},{"text":"key,","start":1709140,"end":1709420,"confidence":0.99121094,"speaker":"A"},{"text":"put","start":1709420,"end":1709700,"confidence":0.9951172,"speaker":"A"},{"text":"in","start":1709700,"end":1709900,"confidence":0.9526367,"speaker":"A"},{"text":"a","start":1709900,"end":1710100,"confidence":0.9555664,"speaker":"A"},{"text":"name","start":1710100,"end":1710300,"confidence":0.9941406,"speaker":"A"},{"text":"you","start":1710300,"end":1710500,"confidence":0.99072266,"speaker":"A"},{"text":"want,","start":1710500,"end":1710780,"confidence":0.70458984,"speaker":"A"},{"text":"it'll","start":1711020,"end":1711460,"confidence":0.9889323,"speaker":"A"},{"text":"actually","start":1711460,"end":1711660,"confidence":0.99902344,"speaker":"A"},{"text":"give","start":1711660,"end":1711860,"confidence":1,"speaker":"A"},{"text":"you","start":1711860,"end":1712020,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1712020,"end":1712180,"confidence":0.9995117,"speaker":"A"},{"text":"command","start":1712180,"end":1712500,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1712500,"end":1712660,"confidence":0.9970703,"speaker":"A"},{"text":"need","start":1712660,"end":1712820,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":1712820,"end":1712980,"confidence":1,"speaker":"A"},{"text":"run","start":1712980,"end":1713260,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1713340,"end":1713620,"confidence":0.99853516,"speaker":"A"},{"text":"then","start":1713620,"end":1713780,"confidence":0.9946289,"speaker":"A"},{"text":"you","start":1713780,"end":1713940,"confidence":0.99853516,"speaker":"A"},{"text":"just","start":1713940,"end":1714099,"confidence":0.9995117,"speaker":"A"},{"text":"paste","start":1714099,"end":1714420,"confidence":0.98950195,"speaker":"A"},{"text":"in","start":1714420,"end":1714580,"confidence":0.9951172,"speaker":"A"},{"text":"the","start":1714580,"end":1714700,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1714700,"end":1714900,"confidence":0.9995117,"speaker":"A"},{"text":"key","start":1714900,"end":1715180,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1715180,"end":1715380,"confidence":0.9169922,"speaker":"A"},{"text":"here.","start":1715380,"end":1715660,"confidence":0.9995117,"speaker":"A"},{"text":"That","start":1716380,"end":1716700,"confidence":0.9980469,"speaker":"A"},{"text":"gives","start":1716700,"end":1717060,"confidence":0.9995117,"speaker":"A"},{"text":"you.","start":1717060,"end":1717340,"confidence":0.9995117,"speaker":"A"},{"text":"That","start":1718780,"end":1719060,"confidence":0.8378906,"speaker":"A"},{"text":"will","start":1719060,"end":1719220,"confidence":0.9951172,"speaker":"A"},{"text":"give","start":1719220,"end":1719380,"confidence":1,"speaker":"A"},{"text":"you","start":1719380,"end":1719540,"confidence":1,"speaker":"A"},{"text":"everything","start":1719540,"end":1719780,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1719780,"end":1720020,"confidence":0.99902344,"speaker":"A"},{"text":"need.","start":1720020,"end":1720300,"confidence":0.9995117,"speaker":"A"}]},{"text":"So here's how to run it. Basically, sorry about that.","start":1720860,"end":1724630,"confidence":0.9995117,"words":[{"text":"So","start":1720860,"end":1721140,"confidence":0.9995117,"speaker":"A"},{"text":"here's","start":1721140,"end":1721540,"confidence":0.9949544,"speaker":"A"},{"text":"how","start":1721540,"end":1721780,"confidence":1,"speaker":"A"},{"text":"to","start":1721780,"end":1721940,"confidence":0.9995117,"speaker":"A"},{"text":"run","start":1721940,"end":1722100,"confidence":1,"speaker":"A"},{"text":"it.","start":1722100,"end":1722300,"confidence":0.99902344,"speaker":"A"},{"text":"Basically,","start":1722300,"end":1722780,"confidence":0.998291,"speaker":"A"},{"text":"sorry","start":1723990,"end":1724190,"confidence":0.9773763,"speaker":"A"},{"text":"about","start":1724190,"end":1724350,"confidence":0.9819336,"speaker":"A"},{"text":"that.","start":1724350,"end":1724630,"confidence":0.9941406,"speaker":"A"}]},{"text":"We just run that. That gives us the key. We can go ahead and get the public key. We can also pipe it to PB Copy and then all we have to do is paste that in the box over here.","start":1737190,"end":1750930,"confidence":0.7998047,"words":[{"text":"We","start":1737190,"end":1737470,"confidence":0.7998047,"speaker":"A"},{"text":"just","start":1737470,"end":1737670,"confidence":0.99853516,"speaker":"A"},{"text":"run","start":1737670,"end":1737870,"confidence":0.9975586,"speaker":"A"},{"text":"that.","start":1737870,"end":1738150,"confidence":0.9970703,"speaker":"A"},{"text":"That","start":1738470,"end":1738750,"confidence":0.9995117,"speaker":"A"},{"text":"gives","start":1738750,"end":1738950,"confidence":0.99975586,"speaker":"A"},{"text":"us","start":1738950,"end":1739070,"confidence":1,"speaker":"A"},{"text":"the","start":1739070,"end":1739230,"confidence":0.9995117,"speaker":"A"},{"text":"key.","start":1739230,"end":1739510,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":1740710,"end":1740990,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":1740990,"end":1741150,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1741150,"end":1741310,"confidence":0.99902344,"speaker":"A"},{"text":"ahead","start":1741310,"end":1741550,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1741550,"end":1741910,"confidence":0.9970703,"speaker":"A"},{"text":"get","start":1742070,"end":1742350,"confidence":1,"speaker":"A"},{"text":"the","start":1742350,"end":1742510,"confidence":1,"speaker":"A"},{"text":"public","start":1742510,"end":1742750,"confidence":1,"speaker":"A"},{"text":"key.","start":1742750,"end":1743110,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":1743190,"end":1743470,"confidence":0.9980469,"speaker":"A"},{"text":"can","start":1743470,"end":1743750,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":1743910,"end":1744270,"confidence":0.99902344,"speaker":"A"},{"text":"pipe","start":1744270,"end":1744670,"confidence":0.9607747,"speaker":"A"},{"text":"it","start":1744670,"end":1744870,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1744870,"end":1745070,"confidence":0.9975586,"speaker":"A"},{"text":"PB","start":1745070,"end":1745390,"confidence":0.79541016,"speaker":"A"},{"text":"Copy","start":1745390,"end":1745990,"confidence":0.9637044,"speaker":"A"},{"text":"and","start":1746470,"end":1746750,"confidence":0.9321289,"speaker":"A"},{"text":"then","start":1746750,"end":1746910,"confidence":0.98779297,"speaker":"A"},{"text":"all","start":1746910,"end":1747070,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":1747070,"end":1747190,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1747190,"end":1747310,"confidence":0.95947266,"speaker":"A"},{"text":"to","start":1747310,"end":1747430,"confidence":0.99609375,"speaker":"A"},{"text":"do","start":1747430,"end":1747590,"confidence":0.99609375,"speaker":"A"},{"text":"is","start":1747590,"end":1747830,"confidence":0.99902344,"speaker":"A"},{"text":"paste","start":1747830,"end":1748110,"confidence":0.9172363,"speaker":"A"},{"text":"that","start":1748110,"end":1748310,"confidence":0.99560547,"speaker":"A"},{"text":"in","start":1748310,"end":1748510,"confidence":0.9970703,"speaker":"A"},{"text":"the","start":1748510,"end":1748670,"confidence":0.99853516,"speaker":"A"},{"text":"box","start":1748670,"end":1749030,"confidence":0.99780273,"speaker":"A"},{"text":"over","start":1750370,"end":1750570,"confidence":0.9951172,"speaker":"A"},{"text":"here.","start":1750570,"end":1750930,"confidence":0.9995117,"speaker":"A"}]},{"text":"There we go.","start":1757970,"end":1758690,"confidence":0.98046875,"words":[{"text":"There","start":1757970,"end":1758250,"confidence":0.98046875,"speaker":"A"},{"text":"we","start":1758250,"end":1758410,"confidence":0.5283203,"speaker":"A"},{"text":"go.","start":1758410,"end":1758690,"confidence":1,"speaker":"A"}]},{"text":"It's pretty complicated to use the server key. We can spell on the miskit code on how to do it because it does a lot of that work for you if you have it. But you will need the, the private key, the key id, I think, I think that's it. And then you should be good with having access now to the public database. So just to go over, there's differences between the public and private database.","start":1765890,"end":1795490,"confidence":0.9930013,"words":[{"text":"It's","start":1765890,"end":1766250,"confidence":0.9930013,"speaker":"A"},{"text":"pretty","start":1766250,"end":1766570,"confidence":0.9998372,"speaker":"A"},{"text":"complicated","start":1766570,"end":1767250,"confidence":1,"speaker":"A"},{"text":"to","start":1767250,"end":1767490,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":1767490,"end":1767770,"confidence":1,"speaker":"A"},{"text":"the","start":1767770,"end":1768010,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1768010,"end":1768450,"confidence":0.99975586,"speaker":"A"},{"text":"key.","start":1768450,"end":1768770,"confidence":0.99560547,"speaker":"A"},{"text":"We","start":1770050,"end":1770330,"confidence":0.9951172,"speaker":"A"},{"text":"can","start":1770330,"end":1770490,"confidence":0.99902344,"speaker":"A"},{"text":"spell","start":1770490,"end":1770770,"confidence":0.9838867,"speaker":"A"},{"text":"on","start":1770770,"end":1771050,"confidence":0.8208008,"speaker":"A"},{"text":"the","start":1771050,"end":1771250,"confidence":0.99658203,"speaker":"A"},{"text":"miskit","start":1771250,"end":1771690,"confidence":0.9238281,"speaker":"A"},{"text":"code","start":1771690,"end":1771970,"confidence":0.99348956,"speaker":"A"},{"text":"on","start":1771970,"end":1772090,"confidence":0.9975586,"speaker":"A"},{"text":"how","start":1772090,"end":1772250,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1772250,"end":1772410,"confidence":0.99902344,"speaker":"A"},{"text":"do","start":1772410,"end":1772570,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":1772570,"end":1772850,"confidence":0.9995117,"speaker":"A"},{"text":"because","start":1773170,"end":1773450,"confidence":0.9663086,"speaker":"A"},{"text":"it","start":1773450,"end":1773610,"confidence":0.9995117,"speaker":"A"},{"text":"does","start":1773610,"end":1773810,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1773810,"end":1773970,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":1773970,"end":1774050,"confidence":1,"speaker":"A"},{"text":"of","start":1774050,"end":1774130,"confidence":0.9980469,"speaker":"A"},{"text":"that","start":1774130,"end":1774290,"confidence":0.99560547,"speaker":"A"},{"text":"work","start":1774290,"end":1774530,"confidence":1,"speaker":"A"},{"text":"for","start":1774530,"end":1774730,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1774730,"end":1774930,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1774930,"end":1775170,"confidence":0.59228516,"speaker":"A"},{"text":"you","start":1775170,"end":1775330,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1775330,"end":1775450,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":1775450,"end":1775730,"confidence":0.9916992,"speaker":"A"},{"text":"But","start":1776610,"end":1776730,"confidence":0.99121094,"speaker":"A"},{"text":"you","start":1776730,"end":1776890,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1776890,"end":1777090,"confidence":0.9995117,"speaker":"A"},{"text":"need","start":1777090,"end":1777410,"confidence":0.9995117,"speaker":"A"},{"text":"the,","start":1777650,"end":1778050,"confidence":0.8984375,"speaker":"A"},{"text":"the","start":1779170,"end":1779490,"confidence":0.98876953,"speaker":"A"},{"text":"private","start":1779490,"end":1779810,"confidence":0.9995117,"speaker":"A"},{"text":"key,","start":1779890,"end":1780290,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":1780290,"end":1780570,"confidence":0.99121094,"speaker":"A"},{"text":"key","start":1780570,"end":1780810,"confidence":0.9946289,"speaker":"A"},{"text":"id,","start":1780810,"end":1781170,"confidence":0.98583984,"speaker":"A"},{"text":"I","start":1782290,"end":1782570,"confidence":0.90771484,"speaker":"A"},{"text":"think,","start":1782570,"end":1782850,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":1783170,"end":1783450,"confidence":0.8652344,"speaker":"A"},{"text":"think","start":1783450,"end":1783610,"confidence":0.9868164,"speaker":"A"},{"text":"that's","start":1783610,"end":1783810,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":1783810,"end":1784050,"confidence":0.9941406,"speaker":"A"},{"text":"And","start":1784370,"end":1784650,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":1784650,"end":1784890,"confidence":0.94677734,"speaker":"A"},{"text":"you","start":1784890,"end":1785130,"confidence":0.99658203,"speaker":"A"},{"text":"should","start":1785130,"end":1785290,"confidence":1,"speaker":"A"},{"text":"be","start":1785290,"end":1785490,"confidence":1,"speaker":"A"},{"text":"good","start":1785490,"end":1785810,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":1786130,"end":1786490,"confidence":0.9975586,"speaker":"A"},{"text":"having","start":1786490,"end":1786810,"confidence":0.9555664,"speaker":"A"},{"text":"access","start":1786810,"end":1787170,"confidence":1,"speaker":"A"},{"text":"now","start":1787170,"end":1787490,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1787490,"end":1787770,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1787770,"end":1788010,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1788010,"end":1788290,"confidence":0.9995117,"speaker":"A"},{"text":"database.","start":1789330,"end":1790130,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":1790850,"end":1791250,"confidence":0.98876953,"speaker":"A"},{"text":"just","start":1791570,"end":1791889,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1791889,"end":1792050,"confidence":0.99853516,"speaker":"A"},{"text":"go","start":1792050,"end":1792209,"confidence":0.99902344,"speaker":"A"},{"text":"over,","start":1792209,"end":1792530,"confidence":1,"speaker":"A"},{"text":"there's","start":1792610,"end":1793050,"confidence":0.9892578,"speaker":"A"},{"text":"differences","start":1793050,"end":1793450,"confidence":0.9995117,"speaker":"A"},{"text":"between","start":1793450,"end":1793770,"confidence":1,"speaker":"A"},{"text":"the","start":1793770,"end":1793970,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1793970,"end":1794210,"confidence":1,"speaker":"A"},{"text":"and","start":1794210,"end":1794490,"confidence":0.99902344,"speaker":"A"},{"text":"private","start":1794490,"end":1794730,"confidence":1,"speaker":"A"},{"text":"database.","start":1794730,"end":1795490,"confidence":0.99820966,"speaker":"A"}]},{"text":"So this is query. You can see my cursor, right? Query and lookup of records is available on all but file changes or, excuse me, record changes. It's not available on public zones, aren't really available in public zone changes aren't available in public notifications. Zone notifications aren't available in public, but query notifications are.","start":1797170,"end":1821990,"confidence":0.99609375,"words":[{"text":"So","start":1797170,"end":1797570,"confidence":0.99609375,"speaker":"A"},{"text":"this","start":1797730,"end":1798050,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1798050,"end":1798370,"confidence":0.9995117,"speaker":"A"},{"text":"query.","start":1798530,"end":1799090,"confidence":0.9975586,"speaker":"A"},{"text":"You","start":1799570,"end":1799810,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1799810,"end":1799930,"confidence":0.5439453,"speaker":"A"},{"text":"see","start":1799930,"end":1800090,"confidence":0.99609375,"speaker":"A"},{"text":"my","start":1800090,"end":1800250,"confidence":0.8847656,"speaker":"A"},{"text":"cursor,","start":1800250,"end":1800650,"confidence":0.9938151,"speaker":"A"},{"text":"right?","start":1800650,"end":1800930,"confidence":0.97265625,"speaker":"A"},{"text":"Query","start":1800930,"end":1801330,"confidence":0.9904785,"speaker":"A"},{"text":"and","start":1801330,"end":1801530,"confidence":0.53759766,"speaker":"A"},{"text":"lookup","start":1801530,"end":1802010,"confidence":0.94018555,"speaker":"A"},{"text":"of","start":1802010,"end":1802330,"confidence":0.9916992,"speaker":"A"},{"text":"records","start":1802330,"end":1803010,"confidence":0.99975586,"speaker":"A"},{"text":"is","start":1803010,"end":1803290,"confidence":0.9995117,"speaker":"A"},{"text":"available","start":1803290,"end":1803570,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":1803650,"end":1803970,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":1803970,"end":1804290,"confidence":0.99658203,"speaker":"A"},{"text":"but","start":1805270,"end":1805510,"confidence":0.9897461,"speaker":"A"},{"text":"file","start":1805590,"end":1806030,"confidence":0.9970703,"speaker":"A"},{"text":"changes","start":1806030,"end":1806630,"confidence":0.9992676,"speaker":"A"},{"text":"or,","start":1806790,"end":1807110,"confidence":0.97314453,"speaker":"A"},{"text":"excuse","start":1807110,"end":1807430,"confidence":0.99820966,"speaker":"A"},{"text":"me,","start":1807430,"end":1807670,"confidence":0.9995117,"speaker":"A"},{"text":"record","start":1807990,"end":1808350,"confidence":0.99609375,"speaker":"A"},{"text":"changes.","start":1808350,"end":1808830,"confidence":0.99975586,"speaker":"A"},{"text":"It's","start":1808830,"end":1809070,"confidence":0.8819987,"speaker":"A"},{"text":"not","start":1809070,"end":1809230,"confidence":1,"speaker":"A"},{"text":"available","start":1809230,"end":1809510,"confidence":0.99853516,"speaker":"A"},{"text":"on","start":1809830,"end":1810150,"confidence":0.9160156,"speaker":"A"},{"text":"public","start":1810150,"end":1810470,"confidence":0.9995117,"speaker":"A"},{"text":"zones,","start":1810950,"end":1811390,"confidence":0.9909668,"speaker":"A"},{"text":"aren't","start":1811390,"end":1811670,"confidence":0.9958496,"speaker":"A"},{"text":"really","start":1811670,"end":1811830,"confidence":1,"speaker":"A"},{"text":"available","start":1811830,"end":1812150,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1812150,"end":1812430,"confidence":0.9394531,"speaker":"A"},{"text":"public","start":1812430,"end":1812710,"confidence":1,"speaker":"A"},{"text":"zone","start":1812790,"end":1813190,"confidence":0.96240234,"speaker":"A"},{"text":"changes","start":1813190,"end":1813550,"confidence":0.8989258,"speaker":"A"},{"text":"aren't","start":1813550,"end":1813870,"confidence":0.9959717,"speaker":"A"},{"text":"available","start":1813870,"end":1814150,"confidence":1,"speaker":"A"},{"text":"in","start":1814470,"end":1814750,"confidence":0.9667969,"speaker":"A"},{"text":"public","start":1814750,"end":1815030,"confidence":1,"speaker":"A"},{"text":"notifications.","start":1815670,"end":1816470,"confidence":0.9949544,"speaker":"A"},{"text":"Zone","start":1816550,"end":1816950,"confidence":0.94677734,"speaker":"A"},{"text":"notifications","start":1816950,"end":1817630,"confidence":0.9996745,"speaker":"A"},{"text":"aren't","start":1817630,"end":1817950,"confidence":0.9765625,"speaker":"A"},{"text":"available","start":1817950,"end":1818230,"confidence":1,"speaker":"A"},{"text":"in","start":1818310,"end":1818590,"confidence":0.9941406,"speaker":"A"},{"text":"public,","start":1818590,"end":1818870,"confidence":1,"speaker":"A"},{"text":"but","start":1819670,"end":1820070,"confidence":0.9921875,"speaker":"A"},{"text":"query","start":1820070,"end":1820550,"confidence":0.82421875,"speaker":"A"},{"text":"notifications","start":1820709,"end":1821510,"confidence":0.9996745,"speaker":"A"},{"text":"are.","start":1821590,"end":1821990,"confidence":0.9902344,"speaker":"A"}]},{"text":"And you can also do any stuff with assets which are basically binary files. You can also do that in all of them. You can't do query notifications on shared. Shared would essentially work like private essentially. So it's just a matter of who.","start":1821990,"end":1840530,"confidence":0.9921875,"words":[{"text":"And","start":1821990,"end":1822390,"confidence":0.9921875,"speaker":"A"},{"text":"you","start":1822390,"end":1822630,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1822630,"end":1822750,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":1822750,"end":1822990,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1822990,"end":1823350,"confidence":1,"speaker":"A"},{"text":"any","start":1823350,"end":1823750,"confidence":0.99853516,"speaker":"A"},{"text":"stuff","start":1823750,"end":1824150,"confidence":0.9996745,"speaker":"A"},{"text":"with","start":1824150,"end":1824470,"confidence":0.98876953,"speaker":"A"},{"text":"assets","start":1824710,"end":1825270,"confidence":0.7792969,"speaker":"A"},{"text":"which","start":1825350,"end":1825630,"confidence":0.99853516,"speaker":"A"},{"text":"are","start":1825630,"end":1825790,"confidence":1,"speaker":"A"},{"text":"basically","start":1825790,"end":1826190,"confidence":0.99975586,"speaker":"A"},{"text":"binary","start":1826190,"end":1826710,"confidence":0.9995117,"speaker":"A"},{"text":"files.","start":1826710,"end":1827030,"confidence":0.99194336,"speaker":"A"},{"text":"You","start":1827030,"end":1827190,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1827190,"end":1827310,"confidence":0.99853516,"speaker":"A"},{"text":"also","start":1827310,"end":1827470,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1827470,"end":1827630,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1827630,"end":1827910,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1828310,"end":1828670,"confidence":0.5600586,"speaker":"A"},{"text":"all","start":1828670,"end":1828910,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1828910,"end":1829070,"confidence":0.99902344,"speaker":"A"},{"text":"them.","start":1829070,"end":1829350,"confidence":0.9145508,"speaker":"A"},{"text":"You","start":1830630,"end":1830910,"confidence":0.99658203,"speaker":"A"},{"text":"can't","start":1830910,"end":1831230,"confidence":0.9586589,"speaker":"A"},{"text":"do","start":1831230,"end":1831590,"confidence":1,"speaker":"A"},{"text":"query","start":1831750,"end":1832190,"confidence":0.970459,"speaker":"A"},{"text":"notifications","start":1832190,"end":1832990,"confidence":0.99934894,"speaker":"A"},{"text":"on","start":1832990,"end":1833270,"confidence":0.98046875,"speaker":"A"},{"text":"shared.","start":1833270,"end":1833830,"confidence":0.99780273,"speaker":"A"},{"text":"Shared","start":1834470,"end":1834910,"confidence":0.9873047,"speaker":"A"},{"text":"would","start":1834910,"end":1835110,"confidence":0.5698242,"speaker":"A"},{"text":"essentially","start":1835110,"end":1835590,"confidence":0.99902344,"speaker":"A"},{"text":"work","start":1835590,"end":1835870,"confidence":1,"speaker":"A"},{"text":"like","start":1835870,"end":1836110,"confidence":0.9980469,"speaker":"A"},{"text":"private","start":1836110,"end":1836390,"confidence":0.99902344,"speaker":"A"},{"text":"essentially.","start":1836850,"end":1837410,"confidence":0.9968262,"speaker":"A"},{"text":"So","start":1837490,"end":1837890,"confidence":0.9946289,"speaker":"A"},{"text":"it's","start":1839090,"end":1839410,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1839410,"end":1839530,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1839530,"end":1839650,"confidence":0.9995117,"speaker":"A"},{"text":"matter","start":1839650,"end":1839810,"confidence":1,"speaker":"A"},{"text":"of","start":1839810,"end":1840130,"confidence":0.99902344,"speaker":"A"},{"text":"who.","start":1840130,"end":1840530,"confidence":0.77685547,"speaker":"A"}]},{"text":"Who's the owner and how is it shared.","start":1840530,"end":1842610,"confidence":0.9977214,"words":[{"text":"Who's","start":1840530,"end":1840930,"confidence":0.9977214,"speaker":"A"},{"text":"the","start":1840930,"end":1841050,"confidence":0.99853516,"speaker":"A"},{"text":"owner","start":1841050,"end":1841370,"confidence":1,"speaker":"A"},{"text":"and","start":1841370,"end":1841570,"confidence":0.99609375,"speaker":"A"},{"text":"how","start":1841570,"end":1841810,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1841810,"end":1841970,"confidence":0.94970703,"speaker":"A"},{"text":"it","start":1841970,"end":1842090,"confidence":0.99902344,"speaker":"A"},{"text":"shared.","start":1842090,"end":1842610,"confidence":0.9968262,"speaker":"A"}]},{"text":"So one of the big challenges I think we've all faced this when we've dealt with certain web services is field type polymorphism. If you've done JSON where you don't know what type you're getting back or what data you're getting back, this can Be a bit challenging. So if you look at the documentation in Web Services Reference, there is a, there's a page called types and dictionaries and there is types. There's different type values for each field. If you're familiar with CloudKit, you've seen this, right?","start":1844690,"end":1878450,"confidence":0.99658203,"words":[{"text":"So","start":1844690,"end":1844930,"confidence":0.99658203,"speaker":"A"},{"text":"one","start":1844930,"end":1845050,"confidence":0.9794922,"speaker":"A"},{"text":"of","start":1845050,"end":1845210,"confidence":1,"speaker":"A"},{"text":"the","start":1845210,"end":1845450,"confidence":0.9995117,"speaker":"A"},{"text":"big","start":1845450,"end":1845730,"confidence":1,"speaker":"A"},{"text":"challenges","start":1845730,"end":1846370,"confidence":0.96468097,"speaker":"A"},{"text":"I","start":1846450,"end":1846730,"confidence":0.99853516,"speaker":"A"},{"text":"think","start":1846730,"end":1846890,"confidence":1,"speaker":"A"},{"text":"we've","start":1846890,"end":1847170,"confidence":0.9977214,"speaker":"A"},{"text":"all","start":1847170,"end":1847330,"confidence":0.9995117,"speaker":"A"},{"text":"faced","start":1847330,"end":1847650,"confidence":0.95825195,"speaker":"A"},{"text":"this","start":1847650,"end":1847810,"confidence":0.99072266,"speaker":"A"},{"text":"when","start":1847810,"end":1848010,"confidence":0.99609375,"speaker":"A"},{"text":"we've","start":1848010,"end":1848370,"confidence":0.98095703,"speaker":"A"},{"text":"dealt","start":1848370,"end":1848650,"confidence":0.9992676,"speaker":"A"},{"text":"with","start":1848650,"end":1848810,"confidence":1,"speaker":"A"},{"text":"certain","start":1848810,"end":1849010,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1849010,"end":1849290,"confidence":0.99902344,"speaker":"A"},{"text":"services","start":1849290,"end":1849570,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1850530,"end":1850930,"confidence":0.98876953,"speaker":"A"},{"text":"field","start":1851410,"end":1851810,"confidence":0.9897461,"speaker":"A"},{"text":"type","start":1851970,"end":1852449,"confidence":0.810791,"speaker":"A"},{"text":"polymorphism.","start":1852449,"end":1853370,"confidence":0.9991862,"speaker":"A"},{"text":"If","start":1853370,"end":1853570,"confidence":1,"speaker":"A"},{"text":"you've","start":1853570,"end":1853730,"confidence":0.9998372,"speaker":"A"},{"text":"done","start":1853730,"end":1853890,"confidence":0.9975586,"speaker":"A"},{"text":"JSON","start":1853890,"end":1854370,"confidence":0.7998047,"speaker":"A"},{"text":"where","start":1854370,"end":1854650,"confidence":0.87939453,"speaker":"A"},{"text":"you","start":1854650,"end":1854850,"confidence":1,"speaker":"A"},{"text":"don't","start":1854850,"end":1855090,"confidence":0.9996745,"speaker":"A"},{"text":"know","start":1855090,"end":1855210,"confidence":0.99902344,"speaker":"A"},{"text":"what","start":1855210,"end":1855370,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":1855370,"end":1855730,"confidence":0.9946289,"speaker":"A"},{"text":"you're","start":1855730,"end":1855970,"confidence":1,"speaker":"A"},{"text":"getting","start":1855970,"end":1856130,"confidence":0.9995117,"speaker":"A"},{"text":"back","start":1856130,"end":1856370,"confidence":0.9980469,"speaker":"A"},{"text":"or","start":1856370,"end":1856570,"confidence":0.9980469,"speaker":"A"},{"text":"what","start":1856570,"end":1856730,"confidence":0.98876953,"speaker":"A"},{"text":"data","start":1856730,"end":1856930,"confidence":0.9980469,"speaker":"A"},{"text":"you're","start":1856930,"end":1857170,"confidence":0.9995117,"speaker":"A"},{"text":"getting","start":1857170,"end":1857370,"confidence":0.9916992,"speaker":"A"},{"text":"back,","start":1857370,"end":1857730,"confidence":0.9526367,"speaker":"A"},{"text":"this","start":1858050,"end":1858330,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1858330,"end":1858490,"confidence":0.99902344,"speaker":"A"},{"text":"Be","start":1858490,"end":1858610,"confidence":1,"speaker":"A"},{"text":"a","start":1858610,"end":1858690,"confidence":0.9995117,"speaker":"A"},{"text":"bit","start":1858690,"end":1858850,"confidence":0.99902344,"speaker":"A"},{"text":"challenging.","start":1858850,"end":1859410,"confidence":0.9601237,"speaker":"A"},{"text":"So","start":1860530,"end":1860930,"confidence":0.9951172,"speaker":"A"},{"text":"if","start":1861730,"end":1862050,"confidence":0.6791992,"speaker":"A"},{"text":"you","start":1862050,"end":1862250,"confidence":1,"speaker":"A"},{"text":"look","start":1862250,"end":1862410,"confidence":1,"speaker":"A"},{"text":"at","start":1862410,"end":1862610,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1862610,"end":1862850,"confidence":0.9980469,"speaker":"A"},{"text":"documentation","start":1862850,"end":1863650,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1864290,"end":1864490,"confidence":0.78466797,"speaker":"A"},{"text":"Web","start":1864490,"end":1864810,"confidence":0.9890137,"speaker":"A"},{"text":"Services","start":1864810,"end":1865090,"confidence":0.99902344,"speaker":"A"},{"text":"Reference,","start":1865090,"end":1865810,"confidence":0.9918213,"speaker":"A"},{"text":"there","start":1866850,"end":1867210,"confidence":0.9921875,"speaker":"A"},{"text":"is","start":1867210,"end":1867570,"confidence":0.99902344,"speaker":"A"},{"text":"a,","start":1867890,"end":1868290,"confidence":0.99853516,"speaker":"A"},{"text":"there's","start":1869090,"end":1869610,"confidence":0.9824219,"speaker":"A"},{"text":"a","start":1869610,"end":1869890,"confidence":0.99902344,"speaker":"A"},{"text":"page","start":1869890,"end":1870290,"confidence":0.9951172,"speaker":"A"},{"text":"called","start":1870290,"end":1870530,"confidence":0.9995117,"speaker":"A"},{"text":"types","start":1870530,"end":1870810,"confidence":0.87719727,"speaker":"A"},{"text":"and","start":1870810,"end":1870970,"confidence":0.9536133,"speaker":"A"},{"text":"dictionaries","start":1870970,"end":1871650,"confidence":0.99609375,"speaker":"A"},{"text":"and","start":1871650,"end":1872010,"confidence":0.99902344,"speaker":"A"},{"text":"there","start":1872010,"end":1872290,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":1872290,"end":1872610,"confidence":0.99609375,"speaker":"A"},{"text":"types.","start":1872610,"end":1873170,"confidence":0.9255371,"speaker":"A"},{"text":"There's","start":1874050,"end":1874410,"confidence":0.98860675,"speaker":"A"},{"text":"different","start":1874410,"end":1874610,"confidence":1,"speaker":"A"},{"text":"type","start":1874610,"end":1875010,"confidence":0.83618164,"speaker":"A"},{"text":"values","start":1875010,"end":1875530,"confidence":0.9992676,"speaker":"A"},{"text":"for","start":1875530,"end":1875690,"confidence":1,"speaker":"A"},{"text":"each","start":1875690,"end":1875930,"confidence":1,"speaker":"A"},{"text":"field.","start":1875930,"end":1876250,"confidence":1,"speaker":"A"},{"text":"If","start":1876250,"end":1876450,"confidence":1,"speaker":"A"},{"text":"you're","start":1876450,"end":1876610,"confidence":1,"speaker":"A"},{"text":"familiar","start":1876610,"end":1876890,"confidence":1,"speaker":"A"},{"text":"with","start":1876890,"end":1877050,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit,","start":1877050,"end":1877530,"confidence":0.953125,"speaker":"A"},{"text":"you've","start":1877530,"end":1877730,"confidence":0.99886066,"speaker":"A"},{"text":"seen","start":1877730,"end":1877890,"confidence":0.9995117,"speaker":"A"},{"text":"this,","start":1877890,"end":1878130,"confidence":0.9980469,"speaker":"A"},{"text":"right?","start":1878130,"end":1878450,"confidence":0.99853516,"speaker":"A"}]},{"text":"So you have an asset which is basically a, a binary file. You have bytes which is essentially a 60 byte base 64 encoded string, date type which is returned as a number. Double is returned as a number because These are the JavaScript types. Int is returned as a number and then there's location reference and then string and list. And how would you like, how do you do adjacent object like this?","start":1879170,"end":1916620,"confidence":0.9995117,"words":[{"text":"So","start":1879170,"end":1879570,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1879570,"end":1879850,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1879850,"end":1880089,"confidence":1,"speaker":"A"},{"text":"an","start":1880089,"end":1880329,"confidence":0.99853516,"speaker":"A"},{"text":"asset","start":1880329,"end":1880650,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":1880650,"end":1880850,"confidence":1,"speaker":"A"},{"text":"is","start":1880850,"end":1881050,"confidence":0.9995117,"speaker":"A"},{"text":"basically","start":1881050,"end":1881490,"confidence":1,"speaker":"A"},{"text":"a,","start":1882210,"end":1882610,"confidence":0.9838867,"speaker":"A"},{"text":"a","start":1884290,"end":1884690,"confidence":0.9995117,"speaker":"A"},{"text":"binary","start":1884690,"end":1885330,"confidence":0.9998372,"speaker":"A"},{"text":"file.","start":1885330,"end":1885810,"confidence":0.69873047,"speaker":"A"},{"text":"You","start":1886850,"end":1887170,"confidence":1,"speaker":"A"},{"text":"have","start":1887170,"end":1887490,"confidence":1,"speaker":"A"},{"text":"bytes","start":1887490,"end":1888210,"confidence":0.8411458,"speaker":"A"},{"text":"which","start":1889090,"end":1889410,"confidence":1,"speaker":"A"},{"text":"is","start":1889410,"end":1889650,"confidence":0.9995117,"speaker":"A"},{"text":"essentially","start":1889650,"end":1890130,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1890130,"end":1890450,"confidence":0.95996094,"speaker":"A"},{"text":"60","start":1890530,"end":1890930,"confidence":0.9458,"speaker":"A"},{"text":"byte","start":1891170,"end":1891650,"confidence":0.9658203,"speaker":"A"},{"text":"base","start":1891860,"end":1892100,"confidence":0.8461914,"speaker":"A"},{"text":"64","start":1892100,"end":1892580,"confidence":0.99829,"speaker":"A"},{"text":"encoded","start":1892580,"end":1893140,"confidence":0.9967448,"speaker":"A"},{"text":"string,","start":1893140,"end":1893620,"confidence":0.9970703,"speaker":"A"},{"text":"date","start":1894740,"end":1895140,"confidence":0.98095703,"speaker":"A"},{"text":"type","start":1895140,"end":1895580,"confidence":0.9716797,"speaker":"A"},{"text":"which","start":1895580,"end":1895820,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1895820,"end":1896060,"confidence":0.99658203,"speaker":"A"},{"text":"returned","start":1896060,"end":1896580,"confidence":0.98876953,"speaker":"A"},{"text":"as","start":1896580,"end":1896700,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1896700,"end":1896860,"confidence":0.9995117,"speaker":"A"},{"text":"number.","start":1896860,"end":1897140,"confidence":0.99560547,"speaker":"A"},{"text":"Double","start":1897780,"end":1898220,"confidence":0.9511719,"speaker":"A"},{"text":"is","start":1898220,"end":1898460,"confidence":0.98779297,"speaker":"A"},{"text":"returned","start":1898460,"end":1898860,"confidence":0.954834,"speaker":"A"},{"text":"as","start":1898860,"end":1899020,"confidence":0.9951172,"speaker":"A"},{"text":"a","start":1899020,"end":1899140,"confidence":0.99853516,"speaker":"A"},{"text":"number","start":1899140,"end":1899380,"confidence":0.99658203,"speaker":"A"},{"text":"because","start":1899940,"end":1900220,"confidence":0.7080078,"speaker":"A"},{"text":"These","start":1900220,"end":1900380,"confidence":0.99658203,"speaker":"A"},{"text":"are","start":1900380,"end":1900500,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1900500,"end":1900620,"confidence":0.9995117,"speaker":"A"},{"text":"JavaScript","start":1900620,"end":1901220,"confidence":0.9517415,"speaker":"A"},{"text":"types.","start":1901220,"end":1901620,"confidence":0.76464844,"speaker":"A"},{"text":"Int","start":1902260,"end":1902660,"confidence":0.57714844,"speaker":"A"},{"text":"is","start":1902820,"end":1903220,"confidence":0.99609375,"speaker":"A"},{"text":"returned","start":1903540,"end":1904060,"confidence":0.9616699,"speaker":"A"},{"text":"as","start":1904060,"end":1904220,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1904220,"end":1904340,"confidence":0.99902344,"speaker":"A"},{"text":"number","start":1904340,"end":1904580,"confidence":0.99609375,"speaker":"A"},{"text":"and","start":1905700,"end":1905980,"confidence":0.9946289,"speaker":"A"},{"text":"then","start":1905980,"end":1906140,"confidence":0.99902344,"speaker":"A"},{"text":"there's","start":1906140,"end":1906420,"confidence":0.85302734,"speaker":"A"},{"text":"location","start":1906420,"end":1906980,"confidence":0.99902344,"speaker":"A"},{"text":"reference","start":1907540,"end":1908260,"confidence":0.8996582,"speaker":"A"},{"text":"and","start":1909300,"end":1909620,"confidence":0.9892578,"speaker":"A"},{"text":"then","start":1909620,"end":1909940,"confidence":0.9980469,"speaker":"A"},{"text":"string","start":1910020,"end":1910500,"confidence":0.9926758,"speaker":"A"},{"text":"and","start":1910500,"end":1910740,"confidence":0.98828125,"speaker":"A"},{"text":"list.","start":1910740,"end":1911060,"confidence":0.99658203,"speaker":"A"},{"text":"And","start":1911620,"end":1912020,"confidence":0.9951172,"speaker":"A"},{"text":"how","start":1912100,"end":1912420,"confidence":0.9980469,"speaker":"A"},{"text":"would","start":1912420,"end":1912620,"confidence":0.94873047,"speaker":"A"},{"text":"you","start":1912620,"end":1912900,"confidence":0.99902344,"speaker":"A"},{"text":"like,","start":1913060,"end":1913420,"confidence":0.9946289,"speaker":"A"},{"text":"how","start":1913420,"end":1913660,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1913660,"end":1913820,"confidence":0.99658203,"speaker":"A"},{"text":"you","start":1913820,"end":1914020,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1914020,"end":1914340,"confidence":0.99902344,"speaker":"A"},{"text":"adjacent","start":1914820,"end":1915620,"confidence":0.7462891,"speaker":"A"},{"text":"object","start":1915780,"end":1916220,"confidence":0.82470703,"speaker":"A"},{"text":"like","start":1916220,"end":1916460,"confidence":0.99902344,"speaker":"A"},{"text":"this?","start":1916460,"end":1916620,"confidence":0.99902344,"speaker":"A"}]},{"text":"How would you even represent this in Swift? Because you don't know what type you're going to get. So like I said, this is a work in progress. Sorry. So what I do, I don't know how much you can see this.","start":1916620,"end":1928710,"confidence":0.9975586,"words":[{"text":"How","start":1916620,"end":1916780,"confidence":0.9975586,"speaker":"A"},{"text":"would","start":1916780,"end":1916940,"confidence":0.99560547,"speaker":"A"},{"text":"you","start":1916940,"end":1917100,"confidence":0.9980469,"speaker":"A"},{"text":"even","start":1917100,"end":1917300,"confidence":0.9995117,"speaker":"A"},{"text":"represent","start":1917300,"end":1917620,"confidence":0.99853516,"speaker":"A"},{"text":"this","start":1917620,"end":1917900,"confidence":0.8857422,"speaker":"A"},{"text":"in","start":1917900,"end":1918060,"confidence":0.9404297,"speaker":"A"},{"text":"Swift?","start":1918060,"end":1918380,"confidence":0.9929199,"speaker":"A"},{"text":"Because","start":1918380,"end":1918580,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1918580,"end":1918740,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":1918740,"end":1918900,"confidence":0.99934894,"speaker":"A"},{"text":"know","start":1918900,"end":1918980,"confidence":0.99902344,"speaker":"A"},{"text":"what","start":1918980,"end":1919100,"confidence":0.9970703,"speaker":"A"},{"text":"type","start":1919100,"end":1919300,"confidence":0.9980469,"speaker":"A"},{"text":"you're","start":1919300,"end":1919460,"confidence":0.99820966,"speaker":"A"},{"text":"going","start":1919460,"end":1919540,"confidence":0.72802734,"speaker":"A"},{"text":"to","start":1919540,"end":1919620,"confidence":0.99902344,"speaker":"A"},{"text":"get.","start":1919620,"end":1919860,"confidence":0.9980469,"speaker":"A"},{"text":"So","start":1921350,"end":1921590,"confidence":0.9604492,"speaker":"A"},{"text":"like","start":1922790,"end":1923070,"confidence":0.99609375,"speaker":"A"},{"text":"I","start":1923070,"end":1923230,"confidence":0.9995117,"speaker":"A"},{"text":"said,","start":1923230,"end":1923390,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1923390,"end":1923550,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1923550,"end":1923710,"confidence":0.9975586,"speaker":"A"},{"text":"a","start":1923710,"end":1923830,"confidence":0.9980469,"speaker":"A"},{"text":"work","start":1923830,"end":1923950,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1923950,"end":1924110,"confidence":0.99902344,"speaker":"A"},{"text":"progress.","start":1924110,"end":1924510,"confidence":0.99975586,"speaker":"A"},{"text":"Sorry.","start":1924510,"end":1924950,"confidence":0.9889323,"speaker":"A"},{"text":"So","start":1925830,"end":1926150,"confidence":0.94628906,"speaker":"A"},{"text":"what","start":1926150,"end":1926350,"confidence":0.99609375,"speaker":"A"},{"text":"I","start":1926350,"end":1926550,"confidence":0.99853516,"speaker":"A"},{"text":"do,","start":1926550,"end":1926870,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":1927190,"end":1927430,"confidence":0.99853516,"speaker":"A"},{"text":"don't","start":1927430,"end":1927590,"confidence":0.9785156,"speaker":"A"},{"text":"know","start":1927590,"end":1927670,"confidence":0.9975586,"speaker":"A"},{"text":"how","start":1927670,"end":1927790,"confidence":0.99902344,"speaker":"A"},{"text":"much","start":1927790,"end":1927950,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1927950,"end":1928110,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1928110,"end":1928270,"confidence":0.7426758,"speaker":"A"},{"text":"see","start":1928270,"end":1928430,"confidence":0.9995117,"speaker":"A"},{"text":"this.","start":1928430,"end":1928710,"confidence":0.9951172,"speaker":"A"}]},{"text":"I'm going to actually move over to my documentation here at this point. So how are we doing on time? We good?","start":1929110,"end":1940070,"confidence":0.99886066,"words":[{"text":"I'm","start":1929110,"end":1929430,"confidence":0.99886066,"speaker":"A"},{"text":"going","start":1929430,"end":1929550,"confidence":0.71240234,"speaker":"A"},{"text":"to","start":1929550,"end":1929710,"confidence":0.99902344,"speaker":"A"},{"text":"actually","start":1929710,"end":1929910,"confidence":0.9975586,"speaker":"A"},{"text":"move","start":1929910,"end":1930150,"confidence":0.9995117,"speaker":"A"},{"text":"over","start":1930150,"end":1930430,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1930430,"end":1930790,"confidence":0.99853516,"speaker":"A"},{"text":"my","start":1932470,"end":1932870,"confidence":0.99902344,"speaker":"A"},{"text":"documentation","start":1932950,"end":1933910,"confidence":0.99990237,"speaker":"A"},{"text":"here","start":1933910,"end":1934310,"confidence":0.99609375,"speaker":"A"},{"text":"at","start":1935270,"end":1935550,"confidence":0.9951172,"speaker":"A"},{"text":"this","start":1935550,"end":1935710,"confidence":1,"speaker":"A"},{"text":"point.","start":1935710,"end":1935990,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1936150,"end":1936550,"confidence":0.9145508,"speaker":"A"},{"text":"how","start":1938310,"end":1938590,"confidence":0.99853516,"speaker":"A"},{"text":"are","start":1938590,"end":1938710,"confidence":0.9394531,"speaker":"A"},{"text":"we","start":1938710,"end":1938830,"confidence":0.42895508,"speaker":"A"},{"text":"doing","start":1938830,"end":1938990,"confidence":0.9980469,"speaker":"A"},{"text":"on","start":1938990,"end":1939190,"confidence":0.99853516,"speaker":"A"},{"text":"time?","start":1939190,"end":1939510,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":1939510,"end":1939790,"confidence":0.7001953,"speaker":"A"},{"text":"good?","start":1939790,"end":1940070,"confidence":0.98876953,"speaker":"A"}]},{"text":"Yeah, I think, I think we're doing good. Okay, cool. Any, do you want to ask questions? I don't have anything right now. Same nothing right now.","start":1942550,"end":1955040,"confidence":0.9842122,"words":[{"text":"Yeah,","start":1942550,"end":1942870,"confidence":0.9842122,"speaker":"B"},{"text":"I","start":1942870,"end":1942990,"confidence":0.59228516,"speaker":"B"},{"text":"think,","start":1942990,"end":1943190,"confidence":0.9770508,"speaker":"B"},{"text":"I","start":1943190,"end":1943350,"confidence":0.96240234,"speaker":"B"},{"text":"think","start":1943350,"end":1943470,"confidence":0.9975586,"speaker":"B"},{"text":"we're","start":1943470,"end":1943670,"confidence":0.99902344,"speaker":"B"},{"text":"doing","start":1943670,"end":1943790,"confidence":0.9980469,"speaker":"B"},{"text":"good.","start":1943790,"end":1944070,"confidence":0.9951172,"speaker":"B"},{"text":"Okay,","start":1944870,"end":1945310,"confidence":0.94189453,"speaker":"A"},{"text":"cool.","start":1945310,"end":1945590,"confidence":0.99780273,"speaker":"A"},{"text":"Any,","start":1945590,"end":1945910,"confidence":0.90234375,"speaker":"A"},{"text":"do","start":1946560,"end":1946640,"confidence":0.70996094,"speaker":"A"},{"text":"you","start":1946640,"end":1946760,"confidence":0.9946289,"speaker":"A"},{"text":"want","start":1946760,"end":1946880,"confidence":0.9321289,"speaker":"A"},{"text":"to","start":1946880,"end":1946960,"confidence":0.9980469,"speaker":"A"},{"text":"ask","start":1946960,"end":1947120,"confidence":0.9995117,"speaker":"A"},{"text":"questions?","start":1947120,"end":1947680,"confidence":0.99975586,"speaker":"A"},{"text":"I","start":1949680,"end":1949960,"confidence":0.9975586,"speaker":"B"},{"text":"don't","start":1949960,"end":1950240,"confidence":0.9991862,"speaker":"B"},{"text":"have","start":1950240,"end":1950480,"confidence":0.9995117,"speaker":"B"},{"text":"anything","start":1950480,"end":1950960,"confidence":0.99975586,"speaker":"B"},{"text":"right","start":1951440,"end":1951800,"confidence":0.99902344,"speaker":"B"},{"text":"now.","start":1951800,"end":1952160,"confidence":0.99853516,"speaker":"B"},{"text":"Same","start":1953760,"end":1954160,"confidence":0.98291016,"speaker":"C"},{"text":"nothing","start":1954240,"end":1954600,"confidence":0.99975586,"speaker":"C"},{"text":"right","start":1954600,"end":1954800,"confidence":0.9995117,"speaker":"C"},{"text":"now.","start":1954800,"end":1955040,"confidence":0.9995117,"speaker":"C"}]},{"text":"But this seems applicable to things I'll be doing coming up. Okay, cool.","start":1955040,"end":1960480,"confidence":0.9980469,"words":[{"text":"But","start":1955040,"end":1955240,"confidence":0.9980469,"speaker":"C"},{"text":"this","start":1955240,"end":1955440,"confidence":0.99853516,"speaker":"C"},{"text":"seems","start":1955440,"end":1955880,"confidence":0.99975586,"speaker":"C"},{"text":"applicable","start":1955880,"end":1956560,"confidence":0.99975586,"speaker":"C"},{"text":"to","start":1956560,"end":1956960,"confidence":0.9995117,"speaker":"C"},{"text":"things","start":1957280,"end":1957600,"confidence":1,"speaker":"C"},{"text":"I'll","start":1957600,"end":1957880,"confidence":0.98779297,"speaker":"C"},{"text":"be","start":1957880,"end":1958000,"confidence":0.9995117,"speaker":"C"},{"text":"doing","start":1958000,"end":1958200,"confidence":0.9995117,"speaker":"C"},{"text":"coming","start":1958200,"end":1958480,"confidence":0.99853516,"speaker":"C"},{"text":"up.","start":1958480,"end":1958800,"confidence":0.99609375,"speaker":"C"},{"text":"Okay,","start":1959360,"end":1960000,"confidence":0.88964844,"speaker":"A"},{"text":"cool.","start":1960000,"end":1960480,"confidence":0.99902344,"speaker":"A"}]},{"text":"So we have set up in the open. So we have an open API YAML file that you can pull up in Miskit, which is basically every like the documentation converted to YAML. And so what we do is you can set up in the YAML the field value requests and they have an enum type essentially for, for open API. So and then, so this has, you know, it could be one of either any of these types of. And then there's an enum in case you have a list.","start":1963200,"end":2003170,"confidence":0.8515625,"words":[{"text":"So","start":1963200,"end":1963600,"confidence":0.8515625,"speaker":"A"},{"text":"we","start":1964480,"end":1964760,"confidence":0.9838867,"speaker":"A"},{"text":"have","start":1964760,"end":1964960,"confidence":0.59765625,"speaker":"A"},{"text":"set","start":1964960,"end":1965200,"confidence":0.99902344,"speaker":"A"},{"text":"up","start":1965200,"end":1965520,"confidence":0.9716797,"speaker":"A"},{"text":"in","start":1965920,"end":1966280,"confidence":0.85595703,"speaker":"A"},{"text":"the","start":1966280,"end":1966640,"confidence":0.98291016,"speaker":"A"},{"text":"open.","start":1966800,"end":1967200,"confidence":0.9916992,"speaker":"A"},{"text":"So","start":1967200,"end":1967440,"confidence":0.93896484,"speaker":"A"},{"text":"we","start":1967440,"end":1967520,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":1967520,"end":1967640,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":1967640,"end":1967760,"confidence":0.9116211,"speaker":"A"},{"text":"open","start":1967760,"end":1967960,"confidence":0.99853516,"speaker":"A"},{"text":"API","start":1967960,"end":1968480,"confidence":0.9958496,"speaker":"A"},{"text":"YAML","start":1968480,"end":1968920,"confidence":0.9547526,"speaker":"A"},{"text":"file","start":1968920,"end":1969360,"confidence":0.99731445,"speaker":"A"},{"text":"that","start":1969760,"end":1970040,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1970040,"end":1970240,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":1970240,"end":1970400,"confidence":0.99853516,"speaker":"A"},{"text":"pull","start":1970400,"end":1970560,"confidence":0.99975586,"speaker":"A"},{"text":"up","start":1970560,"end":1970680,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1970680,"end":1970880,"confidence":0.9970703,"speaker":"A"},{"text":"Miskit,","start":1970880,"end":1971520,"confidence":0.98657227,"speaker":"A"},{"text":"which","start":1972250,"end":1972370,"confidence":0.9975586,"speaker":"A"},{"text":"is","start":1972370,"end":1972650,"confidence":0.99902344,"speaker":"A"},{"text":"basically","start":1972730,"end":1973370,"confidence":0.99975586,"speaker":"A"},{"text":"every","start":1973370,"end":1973770,"confidence":0.99365234,"speaker":"A"},{"text":"like","start":1973770,"end":1974170,"confidence":0.98828125,"speaker":"A"},{"text":"the","start":1975050,"end":1975370,"confidence":0.99902344,"speaker":"A"},{"text":"documentation","start":1975370,"end":1976170,"confidence":0.99912107,"speaker":"A"},{"text":"converted","start":1976330,"end":1977010,"confidence":0.9996745,"speaker":"A"},{"text":"to","start":1977010,"end":1977210,"confidence":0.9975586,"speaker":"A"},{"text":"YAML.","start":1977210,"end":1977850,"confidence":0.71435547,"speaker":"A"},{"text":"And","start":1978410,"end":1978770,"confidence":0.99072266,"speaker":"A"},{"text":"so","start":1978770,"end":1978970,"confidence":1,"speaker":"A"},{"text":"what","start":1978970,"end":1979090,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":1979090,"end":1979290,"confidence":1,"speaker":"A"},{"text":"do","start":1979290,"end":1979570,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":1979570,"end":1979930,"confidence":0.6928711,"speaker":"A"},{"text":"you","start":1980090,"end":1980410,"confidence":1,"speaker":"A"},{"text":"can","start":1980410,"end":1980690,"confidence":1,"speaker":"A"},{"text":"set","start":1980690,"end":1980930,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":1980930,"end":1981210,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":1982490,"end":1982770,"confidence":0.98095703,"speaker":"A"},{"text":"the","start":1982770,"end":1982930,"confidence":0.9951172,"speaker":"A"},{"text":"YAML","start":1982930,"end":1983250,"confidence":0.8038737,"speaker":"A"},{"text":"the","start":1983250,"end":1983410,"confidence":0.97753906,"speaker":"A"},{"text":"field","start":1983410,"end":1983690,"confidence":0.9980469,"speaker":"A"},{"text":"value","start":1983770,"end":1984130,"confidence":1,"speaker":"A"},{"text":"requests","start":1984130,"end":1984690,"confidence":0.8439128,"speaker":"A"},{"text":"and","start":1984690,"end":1984810,"confidence":0.9970703,"speaker":"A"},{"text":"they","start":1984810,"end":1984930,"confidence":1,"speaker":"A"},{"text":"have","start":1984930,"end":1985090,"confidence":1,"speaker":"A"},{"text":"an","start":1985090,"end":1985290,"confidence":0.9633789,"speaker":"A"},{"text":"enum","start":1985290,"end":1985770,"confidence":0.8808594,"speaker":"A"},{"text":"type","start":1985770,"end":1986090,"confidence":0.8652344,"speaker":"A"},{"text":"essentially","start":1986090,"end":1986650,"confidence":0.94311523,"speaker":"A"},{"text":"for,","start":1987930,"end":1988330,"confidence":0.96875,"speaker":"A"},{"text":"for","start":1992090,"end":1992450,"confidence":0.9995117,"speaker":"A"},{"text":"open","start":1992450,"end":1992810,"confidence":0.9995117,"speaker":"A"},{"text":"API.","start":1992970,"end":1993610,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":1993690,"end":1994090,"confidence":0.98583984,"speaker":"A"},{"text":"and","start":1994970,"end":1995250,"confidence":0.9350586,"speaker":"A"},{"text":"then,","start":1995250,"end":1995490,"confidence":0.39233398,"speaker":"A"},{"text":"so","start":1995490,"end":1995770,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":1995770,"end":1996010,"confidence":0.99902344,"speaker":"A"},{"text":"has,","start":1996010,"end":1996330,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1996330,"end":1996570,"confidence":0.6645508,"speaker":"A"},{"text":"know,","start":1996570,"end":1996690,"confidence":0.97998047,"speaker":"A"},{"text":"it","start":1996690,"end":1996810,"confidence":0.9975586,"speaker":"A"},{"text":"could","start":1996810,"end":1996930,"confidence":0.9838867,"speaker":"A"},{"text":"be","start":1996930,"end":1997090,"confidence":1,"speaker":"A"},{"text":"one","start":1997090,"end":1997210,"confidence":0.99853516,"speaker":"A"},{"text":"of","start":1997210,"end":1997410,"confidence":0.99902344,"speaker":"A"},{"text":"either","start":1997410,"end":1997770,"confidence":0.9968262,"speaker":"A"},{"text":"any","start":1997770,"end":1998010,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1998010,"end":1998170,"confidence":1,"speaker":"A"},{"text":"these","start":1998170,"end":1998370,"confidence":0.99902344,"speaker":"A"},{"text":"types","start":1998370,"end":1998810,"confidence":0.9453125,"speaker":"A"},{"text":"of.","start":1998860,"end":1999020,"confidence":0.5004883,"speaker":"A"},{"text":"And","start":2000050,"end":2000210,"confidence":0.97216797,"speaker":"A"},{"text":"then","start":2000210,"end":2000530,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":2000850,"end":2001210,"confidence":0.99560547,"speaker":"A"},{"text":"an","start":2001210,"end":2001370,"confidence":0.76220703,"speaker":"A"},{"text":"enum","start":2001370,"end":2001850,"confidence":0.92211914,"speaker":"A"},{"text":"in","start":2001850,"end":2002090,"confidence":0.9995117,"speaker":"A"},{"text":"case","start":2002090,"end":2002290,"confidence":1,"speaker":"A"},{"text":"you","start":2002290,"end":2002530,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2002530,"end":2002730,"confidence":1,"speaker":"A"},{"text":"a","start":2002730,"end":2002890,"confidence":0.99902344,"speaker":"A"},{"text":"list.","start":2002890,"end":2003170,"confidence":0.9995117,"speaker":"A"}]},{"text":"So if you have a list value type there is an extra property called type and then that will tell you what type the. The list is. And it's homo homomorphic. It's all the same list type. You can't have lists of different types.","start":2004050,"end":2022210,"confidence":0.99560547,"words":[{"text":"So","start":2004050,"end":2004450,"confidence":0.99560547,"speaker":"A"},{"text":"if","start":2005250,"end":2005570,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":2005570,"end":2005770,"confidence":1,"speaker":"A"},{"text":"have","start":2005770,"end":2005970,"confidence":1,"speaker":"A"},{"text":"a","start":2005970,"end":2006210,"confidence":0.99902344,"speaker":"A"},{"text":"list","start":2006210,"end":2006530,"confidence":0.9995117,"speaker":"A"},{"text":"value","start":2006850,"end":2007250,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":2007330,"end":2007890,"confidence":0.99780273,"speaker":"A"},{"text":"there","start":2008530,"end":2008850,"confidence":1,"speaker":"A"},{"text":"is","start":2008850,"end":2009090,"confidence":1,"speaker":"A"},{"text":"an","start":2009090,"end":2009290,"confidence":0.9995117,"speaker":"A"},{"text":"extra","start":2009290,"end":2009690,"confidence":0.99975586,"speaker":"A"},{"text":"property","start":2009690,"end":2010290,"confidence":0.9995117,"speaker":"A"},{"text":"called","start":2010290,"end":2010690,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":2011010,"end":2011450,"confidence":0.81103516,"speaker":"A"},{"text":"and","start":2011450,"end":2011690,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":2011690,"end":2011850,"confidence":0.99365234,"speaker":"A"},{"text":"that","start":2011850,"end":2012010,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":2012010,"end":2012210,"confidence":0.9995117,"speaker":"A"},{"text":"tell","start":2012210,"end":2012410,"confidence":1,"speaker":"A"},{"text":"you","start":2012410,"end":2012570,"confidence":1,"speaker":"A"},{"text":"what","start":2012570,"end":2012810,"confidence":0.59277344,"speaker":"A"},{"text":"type","start":2012810,"end":2013250,"confidence":0.8652344,"speaker":"A"},{"text":"the.","start":2013410,"end":2013810,"confidence":0.98876953,"speaker":"A"},{"text":"The","start":2014450,"end":2014730,"confidence":0.99853516,"speaker":"A"},{"text":"list","start":2014730,"end":2015010,"confidence":0.9995117,"speaker":"A"},{"text":"is.","start":2015010,"end":2015329,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":2015329,"end":2015570,"confidence":0.99365234,"speaker":"A"},{"text":"it's","start":2015570,"end":2016050,"confidence":0.99397784,"speaker":"A"},{"text":"homo","start":2016530,"end":2017250,"confidence":0.8297526,"speaker":"A"},{"text":"homomorphic.","start":2017250,"end":2018450,"confidence":0.99763995,"speaker":"A"},{"text":"It's","start":2018690,"end":2019050,"confidence":0.9720052,"speaker":"A"},{"text":"all","start":2019050,"end":2019210,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":2019210,"end":2019330,"confidence":0.9995117,"speaker":"A"},{"text":"same","start":2019330,"end":2019570,"confidence":0.99902344,"speaker":"A"},{"text":"list","start":2019890,"end":2020210,"confidence":0.97314453,"speaker":"A"},{"text":"type.","start":2020210,"end":2020490,"confidence":0.9848633,"speaker":"A"},{"text":"You","start":2020490,"end":2020610,"confidence":0.9995117,"speaker":"A"},{"text":"can't","start":2020610,"end":2020810,"confidence":0.98567706,"speaker":"A"},{"text":"have","start":2020810,"end":2021010,"confidence":1,"speaker":"A"},{"text":"lists","start":2021010,"end":2021330,"confidence":0.9987793,"speaker":"A"},{"text":"of","start":2021330,"end":2021450,"confidence":0.9995117,"speaker":"A"},{"text":"different","start":2021450,"end":2021690,"confidence":1,"speaker":"A"},{"text":"types.","start":2021690,"end":2022210,"confidence":0.92578125,"speaker":"A"}]},{"text":"And then we have here again field value. Sometimes the type is available, sometimes it's not. But basically we have all the different value types available to us in a CK value. And then this is. Then the Open API generator essentially builds this for me which is.","start":2024050,"end":2049150,"confidence":0.95751953,"words":[{"text":"And","start":2024050,"end":2024450,"confidence":0.95751953,"speaker":"A"},{"text":"then","start":2024610,"end":2025010,"confidence":0.9038086,"speaker":"A"},{"text":"we","start":2026030,"end":2026190,"confidence":0.9941406,"speaker":"A"},{"text":"have","start":2026190,"end":2026470,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":2026470,"end":2026830,"confidence":0.99902344,"speaker":"A"},{"text":"again","start":2028830,"end":2029230,"confidence":0.99853516,"speaker":"A"},{"text":"field","start":2029230,"end":2029590,"confidence":0.9404297,"speaker":"A"},{"text":"value.","start":2029590,"end":2029950,"confidence":0.99902344,"speaker":"A"},{"text":"Sometimes","start":2031390,"end":2031910,"confidence":0.99886066,"speaker":"A"},{"text":"the","start":2031910,"end":2032070,"confidence":0.98876953,"speaker":"A"},{"text":"type","start":2032070,"end":2032310,"confidence":0.9086914,"speaker":"A"},{"text":"is","start":2032310,"end":2032470,"confidence":0.99853516,"speaker":"A"},{"text":"available,","start":2032470,"end":2032750,"confidence":0.9995117,"speaker":"A"},{"text":"sometimes","start":2032910,"end":2033430,"confidence":0.9996745,"speaker":"A"},{"text":"it's","start":2033430,"end":2033750,"confidence":0.99886066,"speaker":"A"},{"text":"not.","start":2033750,"end":2034030,"confidence":0.9995117,"speaker":"A"},{"text":"But","start":2034590,"end":2034910,"confidence":0.99658203,"speaker":"A"},{"text":"basically","start":2034910,"end":2035390,"confidence":0.99975586,"speaker":"A"},{"text":"we","start":2035390,"end":2035670,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2035670,"end":2035910,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2035910,"end":2036150,"confidence":1,"speaker":"A"},{"text":"the","start":2036150,"end":2036310,"confidence":0.9995117,"speaker":"A"},{"text":"different","start":2036310,"end":2036590,"confidence":0.9995117,"speaker":"A"},{"text":"value","start":2036750,"end":2037150,"confidence":0.99902344,"speaker":"A"},{"text":"types","start":2037230,"end":2037710,"confidence":0.99975586,"speaker":"A"},{"text":"available","start":2037710,"end":2038030,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2038190,"end":2038470,"confidence":1,"speaker":"A"},{"text":"us","start":2038470,"end":2038750,"confidence":1,"speaker":"A"},{"text":"in","start":2038830,"end":2039110,"confidence":0.97802734,"speaker":"A"},{"text":"a","start":2039110,"end":2039270,"confidence":0.96728516,"speaker":"A"},{"text":"CK","start":2039270,"end":2039630,"confidence":0.9001465,"speaker":"A"},{"text":"value.","start":2039630,"end":2039950,"confidence":0.9091797,"speaker":"A"},{"text":"And","start":2041950,"end":2042230,"confidence":0.9848633,"speaker":"A"},{"text":"then","start":2042230,"end":2042510,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":2042990,"end":2043310,"confidence":0.99853516,"speaker":"A"},{"text":"is.","start":2043310,"end":2043550,"confidence":0.99902344,"speaker":"A"},{"text":"Then","start":2043550,"end":2043870,"confidence":0.9848633,"speaker":"A"},{"text":"the","start":2044110,"end":2044430,"confidence":0.98828125,"speaker":"A"},{"text":"Open","start":2044430,"end":2044750,"confidence":0.9946289,"speaker":"A"},{"text":"API","start":2045150,"end":2045670,"confidence":0.99780273,"speaker":"A"},{"text":"generator","start":2045670,"end":2046190,"confidence":0.97143555,"speaker":"A"},{"text":"essentially","start":2046190,"end":2046870,"confidence":0.99902344,"speaker":"A"},{"text":"builds","start":2046870,"end":2047310,"confidence":0.9782715,"speaker":"A"},{"text":"this","start":2047310,"end":2047470,"confidence":0.9926758,"speaker":"A"},{"text":"for","start":2047470,"end":2047670,"confidence":0.9838867,"speaker":"A"},{"text":"me","start":2047670,"end":2047950,"confidence":0.99853516,"speaker":"A"},{"text":"which","start":2048510,"end":2048830,"confidence":0.9980469,"speaker":"A"},{"text":"is.","start":2048830,"end":2049150,"confidence":0.9873047,"speaker":"A"}]},{"text":"Has an enum and a struck for field field value request and then it does all the decoding for me. Thankfully I didn't have to do any of it.","start":2049710,"end":2059169,"confidence":0.9980469,"words":[{"text":"Has","start":2049710,"end":2049990,"confidence":0.9980469,"speaker":"A"},{"text":"an","start":2049990,"end":2050150,"confidence":0.47924805,"speaker":"A"},{"text":"enum","start":2050150,"end":2050670,"confidence":0.7680664,"speaker":"A"},{"text":"and","start":2050830,"end":2051110,"confidence":0.9902344,"speaker":"A"},{"text":"a","start":2051110,"end":2051270,"confidence":0.9863281,"speaker":"A"},{"text":"struck","start":2051270,"end":2051510,"confidence":0.7644043,"speaker":"A"},{"text":"for","start":2051510,"end":2051670,"confidence":0.5751953,"speaker":"A"},{"text":"field","start":2051670,"end":2051950,"confidence":0.7363281,"speaker":"A"},{"text":"field","start":2052110,"end":2052510,"confidence":1,"speaker":"A"},{"text":"value","start":2052670,"end":2053070,"confidence":0.99902344,"speaker":"A"},{"text":"request","start":2053070,"end":2053630,"confidence":0.7783203,"speaker":"A"},{"text":"and","start":2055329,"end":2055449,"confidence":0.9321289,"speaker":"A"},{"text":"then","start":2055449,"end":2055609,"confidence":0.9946289,"speaker":"A"},{"text":"it","start":2055609,"end":2055769,"confidence":1,"speaker":"A"},{"text":"does","start":2055769,"end":2055929,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2055929,"end":2056089,"confidence":0.9941406,"speaker":"A"},{"text":"the","start":2056089,"end":2056249,"confidence":0.9946289,"speaker":"A"},{"text":"decoding","start":2056249,"end":2056769,"confidence":0.99886066,"speaker":"A"},{"text":"for","start":2056769,"end":2056969,"confidence":0.99902344,"speaker":"A"},{"text":"me.","start":2056969,"end":2057249,"confidence":1,"speaker":"A"},{"text":"Thankfully","start":2057249,"end":2057849,"confidence":0.99523926,"speaker":"A"},{"text":"I","start":2057849,"end":2058089,"confidence":0.99560547,"speaker":"A"},{"text":"didn't","start":2058089,"end":2058289,"confidence":0.95670575,"speaker":"A"},{"text":"have","start":2058289,"end":2058369,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2058369,"end":2058449,"confidence":0.9980469,"speaker":"A"},{"text":"do","start":2058449,"end":2058569,"confidence":0.91845703,"speaker":"A"},{"text":"any","start":2058569,"end":2058769,"confidence":1,"speaker":"A"},{"text":"of","start":2058769,"end":2058929,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":2058929,"end":2059169,"confidence":0.9975586,"speaker":"A"}]},{"text":"And then yeah, I just wanted to cover that piece where we show how we deal with these kind of like polymorphic types and how those work. The next thing I want to cover is error handling. So if you look at the documentation gives you. If you get an error we get something like this and then that will show you in the. In the table actually shows you what each error means.","start":2063089,"end":2093630,"confidence":0.97021484,"words":[{"text":"And","start":2063089,"end":2063369,"confidence":0.97021484,"speaker":"A"},{"text":"then","start":2063369,"end":2063649,"confidence":0.99658203,"speaker":"A"},{"text":"yeah,","start":2065409,"end":2065809,"confidence":0.94091797,"speaker":"A"},{"text":"I","start":2065809,"end":2066009,"confidence":0.99902344,"speaker":"A"},{"text":"just","start":2066009,"end":2066169,"confidence":0.99902344,"speaker":"A"},{"text":"wanted","start":2066169,"end":2066409,"confidence":0.99780273,"speaker":"A"},{"text":"to","start":2066409,"end":2066569,"confidence":0.99902344,"speaker":"A"},{"text":"cover","start":2066569,"end":2066769,"confidence":1,"speaker":"A"},{"text":"that","start":2066769,"end":2067009,"confidence":0.9995117,"speaker":"A"},{"text":"piece","start":2067009,"end":2067409,"confidence":0.9667969,"speaker":"A"},{"text":"where","start":2067569,"end":2067929,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2067929,"end":2068249,"confidence":0.9995117,"speaker":"A"},{"text":"show","start":2068249,"end":2068609,"confidence":0.99902344,"speaker":"A"},{"text":"how","start":2068929,"end":2069249,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2069249,"end":2069449,"confidence":1,"speaker":"A"},{"text":"deal","start":2069449,"end":2069609,"confidence":1,"speaker":"A"},{"text":"with","start":2069609,"end":2069888,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":2069888,"end":2070209,"confidence":0.99072266,"speaker":"A"},{"text":"kind","start":2070209,"end":2070369,"confidence":0.98876953,"speaker":"A"},{"text":"of","start":2070369,"end":2070529,"confidence":0.5283203,"speaker":"A"},{"text":"like","start":2070529,"end":2070729,"confidence":0.984375,"speaker":"A"},{"text":"polymorphic","start":2070729,"end":2071969,"confidence":0.9777832,"speaker":"A"},{"text":"types","start":2071969,"end":2072529,"confidence":0.76416016,"speaker":"A"},{"text":"and","start":2073249,"end":2073529,"confidence":0.99658203,"speaker":"A"},{"text":"how","start":2073529,"end":2073729,"confidence":0.9995117,"speaker":"A"},{"text":"those","start":2073729,"end":2073969,"confidence":0.99902344,"speaker":"A"},{"text":"work.","start":2073969,"end":2074289,"confidence":0.99853516,"speaker":"A"},{"text":"The","start":2075329,"end":2075569,"confidence":0.9746094,"speaker":"A"},{"text":"next","start":2075569,"end":2075729,"confidence":0.9902344,"speaker":"A"},{"text":"thing","start":2075729,"end":2075889,"confidence":0.9692383,"speaker":"A"},{"text":"I","start":2075889,"end":2075969,"confidence":0.89208984,"speaker":"A"},{"text":"want","start":2075969,"end":2076089,"confidence":0.79052734,"speaker":"A"},{"text":"to","start":2076089,"end":2076209,"confidence":0.99902344,"speaker":"A"},{"text":"cover","start":2076209,"end":2076409,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2076409,"end":2076689,"confidence":0.99853516,"speaker":"A"},{"text":"error","start":2076689,"end":2077009,"confidence":0.914917,"speaker":"A"},{"text":"handling.","start":2077009,"end":2077489,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":2079249,"end":2079529,"confidence":0.99121094,"speaker":"A"},{"text":"if","start":2079529,"end":2079729,"confidence":0.6791992,"speaker":"A"},{"text":"you","start":2079729,"end":2079929,"confidence":1,"speaker":"A"},{"text":"look","start":2079929,"end":2080049,"confidence":1,"speaker":"A"},{"text":"at","start":2080049,"end":2080169,"confidence":1,"speaker":"A"},{"text":"the","start":2080169,"end":2080289,"confidence":1,"speaker":"A"},{"text":"documentation","start":2080289,"end":2081009,"confidence":0.9964844,"speaker":"A"},{"text":"gives","start":2081569,"end":2081969,"confidence":0.9904785,"speaker":"A"},{"text":"you.","start":2081969,"end":2082209,"confidence":0.99658203,"speaker":"A"},{"text":"If","start":2083390,"end":2083510,"confidence":0.98876953,"speaker":"A"},{"text":"you","start":2083510,"end":2083630,"confidence":0.9975586,"speaker":"A"},{"text":"get","start":2083630,"end":2083750,"confidence":0.97509766,"speaker":"A"},{"text":"an","start":2083750,"end":2083910,"confidence":0.9604492,"speaker":"A"},{"text":"error","start":2083910,"end":2084270,"confidence":0.8522949,"speaker":"A"},{"text":"we","start":2085150,"end":2085430,"confidence":0.99121094,"speaker":"A"},{"text":"get","start":2085430,"end":2085630,"confidence":0.71777344,"speaker":"A"},{"text":"something","start":2085630,"end":2085870,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":2085870,"end":2086070,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2086070,"end":2086350,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2088030,"end":2088350,"confidence":0.9238281,"speaker":"A"},{"text":"then","start":2088350,"end":2088630,"confidence":0.9921875,"speaker":"A"},{"text":"that","start":2088630,"end":2088910,"confidence":0.90283203,"speaker":"A"},{"text":"will","start":2088910,"end":2089150,"confidence":0.7714844,"speaker":"A"},{"text":"show","start":2089150,"end":2089350,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2089350,"end":2089630,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":2089870,"end":2090150,"confidence":0.7524414,"speaker":"A"},{"text":"the.","start":2090150,"end":2090350,"confidence":0.80615234,"speaker":"A"},{"text":"In","start":2090350,"end":2090590,"confidence":0.98876953,"speaker":"A"},{"text":"the","start":2090590,"end":2090750,"confidence":0.9995117,"speaker":"A"},{"text":"table","start":2090750,"end":2091070,"confidence":0.9995117,"speaker":"A"},{"text":"actually","start":2091070,"end":2091390,"confidence":0.99853516,"speaker":"A"},{"text":"shows","start":2091390,"end":2091710,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":2091710,"end":2091830,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":2091830,"end":2092030,"confidence":0.9995117,"speaker":"A"},{"text":"each","start":2092030,"end":2092350,"confidence":0.9995117,"speaker":"A"},{"text":"error","start":2092830,"end":2093270,"confidence":0.87854004,"speaker":"A"},{"text":"means.","start":2093270,"end":2093630,"confidence":0.99853516,"speaker":"A"}]},{"text":"So again we do like an enum in YAML. It's basically a string and then we have everything else be a string. And then the open API generator will automatically generate this which gives us the server error code and the error response. It'll also do all this stuff here, which is really nice.","start":2094830,"end":2115500,"confidence":0.9707031,"words":[{"text":"So","start":2094830,"end":2095230,"confidence":0.9707031,"speaker":"A"},{"text":"again","start":2095230,"end":2095630,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2095710,"end":2095990,"confidence":1,"speaker":"A"},{"text":"do","start":2095990,"end":2096150,"confidence":0.9980469,"speaker":"A"},{"text":"like","start":2096150,"end":2096270,"confidence":0.9892578,"speaker":"A"},{"text":"an","start":2096270,"end":2096430,"confidence":0.9868164,"speaker":"A"},{"text":"enum","start":2096430,"end":2096990,"confidence":0.9489746,"speaker":"A"},{"text":"in","start":2097150,"end":2097470,"confidence":0.54541016,"speaker":"A"},{"text":"YAML.","start":2097470,"end":2098110,"confidence":0.94954425,"speaker":"A"},{"text":"It's","start":2098830,"end":2099190,"confidence":0.99853516,"speaker":"A"},{"text":"basically","start":2099190,"end":2099550,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":2099550,"end":2099750,"confidence":0.9970703,"speaker":"A"},{"text":"string","start":2099750,"end":2100110,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2100110,"end":2100310,"confidence":0.99658203,"speaker":"A"},{"text":"then","start":2100310,"end":2100430,"confidence":0.9746094,"speaker":"A"},{"text":"we","start":2100430,"end":2100550,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2100550,"end":2100710,"confidence":0.9995117,"speaker":"A"},{"text":"everything","start":2100710,"end":2100910,"confidence":0.9995117,"speaker":"A"},{"text":"else","start":2100910,"end":2101190,"confidence":0.99975586,"speaker":"A"},{"text":"be","start":2101190,"end":2101350,"confidence":0.98046875,"speaker":"A"},{"text":"a","start":2101350,"end":2101510,"confidence":0.99853516,"speaker":"A"},{"text":"string.","start":2101510,"end":2101950,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":2102590,"end":2102870,"confidence":0.96240234,"speaker":"A"},{"text":"then","start":2102870,"end":2103150,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2103310,"end":2103590,"confidence":0.9946289,"speaker":"A"},{"text":"open","start":2103590,"end":2103790,"confidence":0.9946289,"speaker":"A"},{"text":"API","start":2103790,"end":2104270,"confidence":0.95581055,"speaker":"A"},{"text":"generator","start":2104270,"end":2104790,"confidence":0.998291,"speaker":"A"},{"text":"will","start":2104790,"end":2105030,"confidence":0.9975586,"speaker":"A"},{"text":"automatically","start":2105030,"end":2105590,"confidence":0.8905029,"speaker":"A"},{"text":"generate","start":2105590,"end":2106110,"confidence":1,"speaker":"A"},{"text":"this","start":2106110,"end":2106430,"confidence":0.9970703,"speaker":"A"},{"text":"which","start":2107710,"end":2108110,"confidence":0.9975586,"speaker":"A"},{"text":"gives","start":2108110,"end":2108510,"confidence":0.9970703,"speaker":"A"},{"text":"us","start":2108510,"end":2108630,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":2108630,"end":2108910,"confidence":0.53759766,"speaker":"A"},{"text":"server","start":2109500,"end":2109860,"confidence":0.9980469,"speaker":"A"},{"text":"error","start":2109860,"end":2110140,"confidence":0.986084,"speaker":"A"},{"text":"code","start":2110140,"end":2110500,"confidence":0.9977214,"speaker":"A"},{"text":"and","start":2110500,"end":2110740,"confidence":0.9145508,"speaker":"A"},{"text":"the","start":2110740,"end":2110980,"confidence":0.95751953,"speaker":"A"},{"text":"error","start":2110980,"end":2111220,"confidence":0.9855957,"speaker":"A"},{"text":"response.","start":2111220,"end":2111820,"confidence":0.89868164,"speaker":"A"},{"text":"It'll","start":2112380,"end":2112820,"confidence":0.9863281,"speaker":"A"},{"text":"also","start":2112820,"end":2113060,"confidence":1,"speaker":"A"},{"text":"do","start":2113060,"end":2113300,"confidence":1,"speaker":"A"},{"text":"all","start":2113300,"end":2113460,"confidence":1,"speaker":"A"},{"text":"this","start":2113460,"end":2113660,"confidence":0.61621094,"speaker":"A"},{"text":"stuff","start":2113660,"end":2113980,"confidence":1,"speaker":"A"},{"text":"here,","start":2113980,"end":2114260,"confidence":1,"speaker":"A"},{"text":"which","start":2114260,"end":2114580,"confidence":0.9399414,"speaker":"A"},{"text":"is","start":2114580,"end":2114820,"confidence":0.99658203,"speaker":"A"},{"text":"really","start":2114820,"end":2115060,"confidence":0.74316406,"speaker":"A"},{"text":"nice.","start":2115060,"end":2115500,"confidence":1,"speaker":"A"}]},{"text":"And then we've then in our. We've abstracted a lot of this in miskit. So that way we also have now a cloud cloud error type which gives us a lot more info regarding that.","start":2117980,"end":2131820,"confidence":0.9970703,"words":[{"text":"And","start":2117980,"end":2118260,"confidence":0.9970703,"speaker":"A"},{"text":"then","start":2118260,"end":2118540,"confidence":0.9995117,"speaker":"A"},{"text":"we've","start":2118620,"end":2119180,"confidence":0.9142253,"speaker":"A"},{"text":"then","start":2119180,"end":2119500,"confidence":0.953125,"speaker":"A"},{"text":"in","start":2119500,"end":2119700,"confidence":0.984375,"speaker":"A"},{"text":"our.","start":2119700,"end":2119980,"confidence":0.9980469,"speaker":"A"},{"text":"We've","start":2120140,"end":2120500,"confidence":0.9944661,"speaker":"A"},{"text":"abstracted","start":2120500,"end":2121220,"confidence":0.9979248,"speaker":"A"},{"text":"a","start":2121220,"end":2121340,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":2121340,"end":2121460,"confidence":1,"speaker":"A"},{"text":"of","start":2121460,"end":2121580,"confidence":1,"speaker":"A"},{"text":"this","start":2121580,"end":2121740,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":2121740,"end":2121940,"confidence":0.72802734,"speaker":"A"},{"text":"miskit.","start":2121940,"end":2122620,"confidence":0.83813477,"speaker":"A"},{"text":"So","start":2122940,"end":2123180,"confidence":1,"speaker":"A"},{"text":"that","start":2123180,"end":2123340,"confidence":1,"speaker":"A"},{"text":"way","start":2123340,"end":2123660,"confidence":0.99902344,"speaker":"A"},{"text":"we","start":2123980,"end":2124260,"confidence":1,"speaker":"A"},{"text":"also","start":2124260,"end":2124460,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2124460,"end":2124740,"confidence":1,"speaker":"A"},{"text":"now","start":2124740,"end":2125100,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":2125580,"end":2125860,"confidence":0.99658203,"speaker":"A"},{"text":"cloud","start":2125860,"end":2126220,"confidence":0.9638672,"speaker":"A"},{"text":"cloud","start":2126540,"end":2127100,"confidence":0.9489746,"speaker":"A"},{"text":"error","start":2127100,"end":2127500,"confidence":0.94311523,"speaker":"A"},{"text":"type","start":2127500,"end":2127980,"confidence":0.99975586,"speaker":"A"},{"text":"which","start":2128540,"end":2128900,"confidence":1,"speaker":"A"},{"text":"gives","start":2128900,"end":2129220,"confidence":1,"speaker":"A"},{"text":"us","start":2129220,"end":2129380,"confidence":1,"speaker":"A"},{"text":"a","start":2129380,"end":2129500,"confidence":1,"speaker":"A"},{"text":"lot","start":2129500,"end":2129660,"confidence":1,"speaker":"A"},{"text":"more","start":2129660,"end":2129980,"confidence":0.9995117,"speaker":"A"},{"text":"info","start":2130060,"end":2130700,"confidence":0.99975586,"speaker":"A"},{"text":"regarding","start":2130860,"end":2131460,"confidence":0.87874347,"speaker":"A"},{"text":"that.","start":2131460,"end":2131820,"confidence":0.99853516,"speaker":"A"}]},{"text":"So that's how we handle errors. And everything I do in the abs, the more abstract higher up stuff is done using type throws like I have type throws and everything. So that's how I handle that. Let me check one last piece I wanted to cover.","start":2133900,"end":2152200,"confidence":0.9975586,"words":[{"text":"So","start":2133900,"end":2134220,"confidence":0.9975586,"speaker":"A"},{"text":"that's","start":2134220,"end":2134540,"confidence":0.9998372,"speaker":"A"},{"text":"how","start":2134540,"end":2134660,"confidence":1,"speaker":"A"},{"text":"we","start":2134660,"end":2134820,"confidence":1,"speaker":"A"},{"text":"handle","start":2134820,"end":2135180,"confidence":0.99975586,"speaker":"A"},{"text":"errors.","start":2135180,"end":2135740,"confidence":0.99912107,"speaker":"A"},{"text":"And","start":2135820,"end":2136140,"confidence":0.99658203,"speaker":"A"},{"text":"everything","start":2136140,"end":2136460,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2137240,"end":2137360,"confidence":0.9736328,"speaker":"A"},{"text":"do","start":2137360,"end":2137520,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":2137520,"end":2137680,"confidence":0.90283203,"speaker":"A"},{"text":"the","start":2137680,"end":2137800,"confidence":0.92822266,"speaker":"A"},{"text":"abs,","start":2137800,"end":2138080,"confidence":0.4827881,"speaker":"A"},{"text":"the","start":2138080,"end":2138360,"confidence":0.9897461,"speaker":"A"},{"text":"more","start":2138360,"end":2138600,"confidence":0.99072266,"speaker":"A"},{"text":"abstract","start":2138600,"end":2138960,"confidence":0.8538411,"speaker":"A"},{"text":"higher","start":2138960,"end":2139280,"confidence":0.99365234,"speaker":"A"},{"text":"up","start":2139280,"end":2139560,"confidence":0.9970703,"speaker":"A"},{"text":"stuff","start":2139560,"end":2139960,"confidence":0.9713542,"speaker":"A"},{"text":"is","start":2140280,"end":2140680,"confidence":0.99902344,"speaker":"A"},{"text":"done","start":2140680,"end":2141080,"confidence":0.9995117,"speaker":"A"},{"text":"using","start":2141800,"end":2142200,"confidence":1,"speaker":"A"},{"text":"type","start":2142360,"end":2142840,"confidence":0.77783203,"speaker":"A"},{"text":"throws","start":2142840,"end":2143320,"confidence":0.9947917,"speaker":"A"},{"text":"like","start":2143320,"end":2143560,"confidence":0.9794922,"speaker":"A"},{"text":"I","start":2143560,"end":2143760,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2143760,"end":2143960,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":2143960,"end":2144240,"confidence":0.7751465,"speaker":"A"},{"text":"throws","start":2144240,"end":2144560,"confidence":0.9274089,"speaker":"A"},{"text":"and","start":2144560,"end":2144680,"confidence":0.5439453,"speaker":"A"},{"text":"everything.","start":2144680,"end":2144920,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2145160,"end":2145560,"confidence":0.9941406,"speaker":"A"},{"text":"that's","start":2145960,"end":2146360,"confidence":0.9996745,"speaker":"A"},{"text":"how","start":2146360,"end":2146440,"confidence":1,"speaker":"A"},{"text":"I","start":2146440,"end":2146560,"confidence":0.9995117,"speaker":"A"},{"text":"handle","start":2146560,"end":2146960,"confidence":0.9951172,"speaker":"A"},{"text":"that.","start":2146960,"end":2147240,"confidence":0.9970703,"speaker":"A"},{"text":"Let","start":2148600,"end":2148880,"confidence":0.97753906,"speaker":"A"},{"text":"me","start":2148880,"end":2149040,"confidence":0.9995117,"speaker":"A"},{"text":"check","start":2149040,"end":2149400,"confidence":0.99780273,"speaker":"A"},{"text":"one","start":2150600,"end":2150920,"confidence":0.99560547,"speaker":"A"},{"text":"last","start":2150920,"end":2151160,"confidence":0.99853516,"speaker":"A"},{"text":"piece","start":2151160,"end":2151440,"confidence":1,"speaker":"A"},{"text":"I","start":2151440,"end":2151560,"confidence":0.99853516,"speaker":"A"},{"text":"wanted","start":2151560,"end":2151800,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2151800,"end":2151920,"confidence":0.99902344,"speaker":"A"},{"text":"cover.","start":2151920,"end":2152200,"confidence":0.9980469,"speaker":"A"}]},{"text":"The last piece I want to cover is really cool. And that is the authentication layer. So Open API provides what's called middleware and that allows you to, when you create a client or a server, you can plug that in and it will handle like let's say you need to make modifications with the request or response. When it comes in, you can intercept it and make whatever modifications you want to make. And in this case what we've done is I've created an authentication middleware which then sees if you have what's called a token manager and an authentic you have that and an authentication method.","start":2154920,"end":2197590,"confidence":0.3737793,"words":[{"text":"The","start":2154920,"end":2155200,"confidence":0.3737793,"speaker":"A"},{"text":"last","start":2155200,"end":2155360,"confidence":0.9980469,"speaker":"A"},{"text":"piece","start":2155360,"end":2155600,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2155600,"end":2155720,"confidence":0.97998047,"speaker":"A"},{"text":"want","start":2155720,"end":2155840,"confidence":0.9321289,"speaker":"A"},{"text":"to","start":2155840,"end":2155960,"confidence":0.9916992,"speaker":"A"},{"text":"cover","start":2155960,"end":2156160,"confidence":1,"speaker":"A"},{"text":"is","start":2156160,"end":2156520,"confidence":0.99902344,"speaker":"A"},{"text":"really","start":2156760,"end":2157120,"confidence":0.9995117,"speaker":"A"},{"text":"cool.","start":2157120,"end":2157440,"confidence":0.99975586,"speaker":"A"},{"text":"And","start":2157440,"end":2157680,"confidence":0.7548828,"speaker":"A"},{"text":"that","start":2157680,"end":2157920,"confidence":1,"speaker":"A"},{"text":"is","start":2157920,"end":2158200,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2158200,"end":2158520,"confidence":1,"speaker":"A"},{"text":"authentication","start":2158520,"end":2159280,"confidence":0.9998779,"speaker":"A"},{"text":"layer.","start":2159280,"end":2159800,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":2160200,"end":2160480,"confidence":0.9770508,"speaker":"A"},{"text":"Open","start":2160480,"end":2160720,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":2160720,"end":2161320,"confidence":0.9436035,"speaker":"A"},{"text":"provides","start":2161320,"end":2161920,"confidence":0.99975586,"speaker":"A"},{"text":"what's","start":2161920,"end":2162240,"confidence":0.99902344,"speaker":"A"},{"text":"called","start":2162240,"end":2162480,"confidence":1,"speaker":"A"},{"text":"middleware","start":2162480,"end":2163160,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2164440,"end":2164680,"confidence":0.9550781,"speaker":"A"},{"text":"that","start":2164760,"end":2165080,"confidence":0.99902344,"speaker":"A"},{"text":"allows","start":2165080,"end":2165440,"confidence":1,"speaker":"A"},{"text":"you","start":2165440,"end":2165640,"confidence":0.9995117,"speaker":"A"},{"text":"to,","start":2165640,"end":2165960,"confidence":0.99072266,"speaker":"A"},{"text":"when","start":2166200,"end":2166480,"confidence":0.99658203,"speaker":"A"},{"text":"you","start":2166480,"end":2166600,"confidence":0.9892578,"speaker":"A"},{"text":"create","start":2166600,"end":2166720,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2166720,"end":2166880,"confidence":0.99902344,"speaker":"A"},{"text":"client","start":2166880,"end":2167120,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":2167120,"end":2167320,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2167320,"end":2167520,"confidence":0.9916992,"speaker":"A"},{"text":"server,","start":2167520,"end":2167840,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":2167840,"end":2167960,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":2167960,"end":2168080,"confidence":1,"speaker":"A"},{"text":"plug","start":2168080,"end":2168360,"confidence":0.99975586,"speaker":"A"},{"text":"that","start":2168360,"end":2168560,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":2168560,"end":2168760,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":2168760,"end":2168960,"confidence":0.9980469,"speaker":"A"},{"text":"it","start":2168960,"end":2169120,"confidence":0.99902344,"speaker":"A"},{"text":"will","start":2169120,"end":2169280,"confidence":0.99902344,"speaker":"A"},{"text":"handle","start":2169280,"end":2169800,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":2169880,"end":2170240,"confidence":0.9291992,"speaker":"A"},{"text":"let's","start":2170240,"end":2170520,"confidence":0.99934894,"speaker":"A"},{"text":"say","start":2170520,"end":2170640,"confidence":1,"speaker":"A"},{"text":"you","start":2170640,"end":2170760,"confidence":1,"speaker":"A"},{"text":"need","start":2170760,"end":2170880,"confidence":1,"speaker":"A"},{"text":"to","start":2170880,"end":2171000,"confidence":1,"speaker":"A"},{"text":"make","start":2171000,"end":2171120,"confidence":1,"speaker":"A"},{"text":"modifications","start":2171120,"end":2171840,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":2171840,"end":2172080,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2172080,"end":2172240,"confidence":0.9951172,"speaker":"A"},{"text":"request","start":2172240,"end":2172600,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":2172600,"end":2172800,"confidence":0.98779297,"speaker":"A"},{"text":"response.","start":2172800,"end":2173400,"confidence":0.9970703,"speaker":"A"},{"text":"When","start":2173640,"end":2173920,"confidence":1,"speaker":"A"},{"text":"it","start":2173920,"end":2174080,"confidence":0.99902344,"speaker":"A"},{"text":"comes","start":2174080,"end":2174280,"confidence":1,"speaker":"A"},{"text":"in,","start":2174280,"end":2174600,"confidence":0.99658203,"speaker":"A"},{"text":"you","start":2174680,"end":2174960,"confidence":1,"speaker":"A"},{"text":"can","start":2174960,"end":2175120,"confidence":0.9995117,"speaker":"A"},{"text":"intercept","start":2175120,"end":2175520,"confidence":0.8586426,"speaker":"A"},{"text":"it","start":2175520,"end":2175760,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2175760,"end":2175880,"confidence":0.9995117,"speaker":"A"},{"text":"make","start":2175880,"end":2176040,"confidence":0.9995117,"speaker":"A"},{"text":"whatever","start":2176040,"end":2176360,"confidence":0.9995117,"speaker":"A"},{"text":"modifications","start":2176360,"end":2177040,"confidence":0.99886066,"speaker":"A"},{"text":"you","start":2177040,"end":2177280,"confidence":0.9995117,"speaker":"A"},{"text":"want","start":2177280,"end":2177440,"confidence":0.9277344,"speaker":"A"},{"text":"to","start":2177440,"end":2177560,"confidence":0.9980469,"speaker":"A"},{"text":"make.","start":2177560,"end":2177800,"confidence":0.9980469,"speaker":"A"},{"text":"And","start":2179239,"end":2179519,"confidence":0.9013672,"speaker":"A"},{"text":"in","start":2179519,"end":2179640,"confidence":1,"speaker":"A"},{"text":"this","start":2179640,"end":2179800,"confidence":1,"speaker":"A"},{"text":"case","start":2179800,"end":2180120,"confidence":1,"speaker":"A"},{"text":"what","start":2180840,"end":2181160,"confidence":0.9995117,"speaker":"A"},{"text":"we've","start":2181160,"end":2181440,"confidence":0.9941406,"speaker":"A"},{"text":"done","start":2181440,"end":2181720,"confidence":1,"speaker":"A"},{"text":"is","start":2181720,"end":2182120,"confidence":0.9970703,"speaker":"A"},{"text":"I've","start":2182520,"end":2182880,"confidence":0.9954427,"speaker":"A"},{"text":"created","start":2182880,"end":2183320,"confidence":0.99975586,"speaker":"A"},{"text":"an","start":2184520,"end":2184840,"confidence":0.9926758,"speaker":"A"},{"text":"authentication","start":2184840,"end":2185480,"confidence":1,"speaker":"A"},{"text":"middleware","start":2185480,"end":2186200,"confidence":0.9993164,"speaker":"A"},{"text":"which","start":2187480,"end":2187840,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":2187840,"end":2188200,"confidence":0.99902344,"speaker":"A"},{"text":"sees","start":2188600,"end":2189080,"confidence":0.8354492,"speaker":"A"},{"text":"if","start":2189080,"end":2189280,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":2189280,"end":2189480,"confidence":0.99365234,"speaker":"A"},{"text":"have","start":2189480,"end":2189800,"confidence":0.9946289,"speaker":"A"},{"text":"what's","start":2191430,"end":2191670,"confidence":0.9420573,"speaker":"A"},{"text":"called","start":2191670,"end":2191790,"confidence":1,"speaker":"A"},{"text":"a","start":2191790,"end":2191910,"confidence":0.9916992,"speaker":"A"},{"text":"token","start":2191910,"end":2192270,"confidence":0.9996745,"speaker":"A"},{"text":"manager","start":2192270,"end":2192870,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2193990,"end":2194390,"confidence":0.98828125,"speaker":"A"},{"text":"an","start":2194390,"end":2194750,"confidence":0.7910156,"speaker":"A"},{"text":"authentic","start":2194750,"end":2195310,"confidence":0.97542316,"speaker":"A"},{"text":"you","start":2195310,"end":2195470,"confidence":0.9970703,"speaker":"A"},{"text":"have","start":2195470,"end":2195630,"confidence":1,"speaker":"A"},{"text":"that","start":2195630,"end":2195870,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2195870,"end":2196190,"confidence":0.9975586,"speaker":"A"},{"text":"an","start":2196190,"end":2196430,"confidence":0.9980469,"speaker":"A"},{"text":"authentication","start":2196430,"end":2197070,"confidence":0.99938965,"speaker":"A"},{"text":"method.","start":2197070,"end":2197590,"confidence":0.9983724,"speaker":"A"}]},{"text":"And the way it works is you pick what type of authentication you want to use. If you already have like a pre existing web token or you already have, or you, you know, have your key ID and your private key already, or you just have the API token. We've created basically a middleware that uses that. So this is how it creates the headers for server to server. So it does all this for us.","start":2198070,"end":2224160,"confidence":0.9921875,"words":[{"text":"And","start":2198070,"end":2198430,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":2198430,"end":2198670,"confidence":1,"speaker":"A"},{"text":"way","start":2198670,"end":2198790,"confidence":1,"speaker":"A"},{"text":"it","start":2198790,"end":2198910,"confidence":0.99902344,"speaker":"A"},{"text":"works","start":2198910,"end":2199350,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2199510,"end":2199910,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":2199910,"end":2200230,"confidence":1,"speaker":"A"},{"text":"pick","start":2200230,"end":2200550,"confidence":0.99853516,"speaker":"A"},{"text":"what","start":2201190,"end":2201550,"confidence":0.99365234,"speaker":"A"},{"text":"type","start":2201550,"end":2201830,"confidence":0.99975586,"speaker":"A"},{"text":"of","start":2201830,"end":2201990,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":2201990,"end":2202550,"confidence":0.9998779,"speaker":"A"},{"text":"you","start":2202550,"end":2202710,"confidence":0.99902344,"speaker":"A"},{"text":"want","start":2202710,"end":2202830,"confidence":0.9165039,"speaker":"A"},{"text":"to","start":2202830,"end":2202950,"confidence":0.99609375,"speaker":"A"},{"text":"use.","start":2202950,"end":2203070,"confidence":1,"speaker":"A"},{"text":"If","start":2203070,"end":2203230,"confidence":1,"speaker":"A"},{"text":"you","start":2203230,"end":2203350,"confidence":1,"speaker":"A"},{"text":"already","start":2203350,"end":2203510,"confidence":0.99853516,"speaker":"A"},{"text":"have","start":2203510,"end":2203670,"confidence":1,"speaker":"A"},{"text":"like","start":2203670,"end":2203790,"confidence":0.99560547,"speaker":"A"},{"text":"a","start":2203790,"end":2203910,"confidence":0.9995117,"speaker":"A"},{"text":"pre","start":2203910,"end":2204030,"confidence":1,"speaker":"A"},{"text":"existing","start":2204030,"end":2204430,"confidence":0.98551434,"speaker":"A"},{"text":"web","start":2204430,"end":2204670,"confidence":0.99975586,"speaker":"A"},{"text":"token","start":2204670,"end":2205190,"confidence":0.9552409,"speaker":"A"},{"text":"or","start":2205590,"end":2205950,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":2205950,"end":2206190,"confidence":0.99853516,"speaker":"A"},{"text":"already","start":2206190,"end":2206470,"confidence":0.99853516,"speaker":"A"},{"text":"have,","start":2206470,"end":2206789,"confidence":0.92626953,"speaker":"A"},{"text":"or","start":2206789,"end":2207070,"confidence":0.95996094,"speaker":"A"},{"text":"you,","start":2207070,"end":2207350,"confidence":0.9916992,"speaker":"A"},{"text":"you","start":2207350,"end":2207550,"confidence":0.9770508,"speaker":"A"},{"text":"know,","start":2207550,"end":2207710,"confidence":0.9716797,"speaker":"A"},{"text":"have","start":2207710,"end":2207910,"confidence":0.6328125,"speaker":"A"},{"text":"your","start":2207910,"end":2208110,"confidence":0.99853516,"speaker":"A"},{"text":"key","start":2208110,"end":2208310,"confidence":0.99609375,"speaker":"A"},{"text":"ID","start":2208310,"end":2208590,"confidence":0.97753906,"speaker":"A"},{"text":"and","start":2208590,"end":2208830,"confidence":0.99902344,"speaker":"A"},{"text":"your","start":2208830,"end":2208990,"confidence":0.99902344,"speaker":"A"},{"text":"private","start":2208990,"end":2209230,"confidence":1,"speaker":"A"},{"text":"key","start":2209230,"end":2209510,"confidence":0.9995117,"speaker":"A"},{"text":"already,","start":2209510,"end":2209830,"confidence":0.99560547,"speaker":"A"},{"text":"or","start":2209910,"end":2210190,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2210190,"end":2210350,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":2210350,"end":2210510,"confidence":1,"speaker":"A"},{"text":"have","start":2210510,"end":2210670,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2210670,"end":2210790,"confidence":0.98339844,"speaker":"A"},{"text":"API","start":2210790,"end":2211190,"confidence":0.9992676,"speaker":"A"},{"text":"token.","start":2211190,"end":2211750,"confidence":0.99934894,"speaker":"A"},{"text":"We've","start":2212390,"end":2212790,"confidence":0.9996745,"speaker":"A"},{"text":"created","start":2212790,"end":2213190,"confidence":0.9995117,"speaker":"A"},{"text":"basically","start":2213190,"end":2213590,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2213590,"end":2213750,"confidence":0.99609375,"speaker":"A"},{"text":"middleware","start":2213750,"end":2214270,"confidence":0.99716794,"speaker":"A"},{"text":"that","start":2214270,"end":2214470,"confidence":0.99902344,"speaker":"A"},{"text":"uses","start":2214470,"end":2214870,"confidence":0.9992676,"speaker":"A"},{"text":"that.","start":2214870,"end":2215190,"confidence":0.98339844,"speaker":"A"},{"text":"So","start":2216560,"end":2216800,"confidence":0.7055664,"speaker":"A"},{"text":"this","start":2218880,"end":2219120,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2219120,"end":2219280,"confidence":0.99902344,"speaker":"A"},{"text":"how","start":2219280,"end":2219560,"confidence":1,"speaker":"A"},{"text":"it","start":2219560,"end":2219840,"confidence":0.9995117,"speaker":"A"},{"text":"creates","start":2219840,"end":2220200,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2220200,"end":2220360,"confidence":0.9995117,"speaker":"A"},{"text":"headers","start":2220360,"end":2220800,"confidence":0.99902344,"speaker":"A"},{"text":"for","start":2221040,"end":2221360,"confidence":0.98583984,"speaker":"A"},{"text":"server","start":2221360,"end":2221720,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":2221720,"end":2221920,"confidence":0.96972656,"speaker":"A"},{"text":"server.","start":2221920,"end":2222400,"confidence":0.9992676,"speaker":"A"},{"text":"So","start":2222800,"end":2223040,"confidence":0.8354492,"speaker":"A"},{"text":"it","start":2223040,"end":2223160,"confidence":0.98583984,"speaker":"A"},{"text":"does","start":2223160,"end":2223320,"confidence":1,"speaker":"A"},{"text":"all","start":2223320,"end":2223480,"confidence":1,"speaker":"A"},{"text":"this","start":2223480,"end":2223640,"confidence":0.9970703,"speaker":"A"},{"text":"for","start":2223640,"end":2223840,"confidence":0.9995117,"speaker":"A"},{"text":"us.","start":2223840,"end":2224160,"confidence":0.99072266,"speaker":"A"}]},{"text":"And then what I added, which I think is really nice, is called the adaptive token manager. And the idea with that is like let's say you're using a client and you have the web authentication token now and then this allows you to upgrade with that web authentication token to the private database and have access to that.","start":2225760,"end":2247730,"confidence":0.6791992,"words":[{"text":"And","start":2225760,"end":2226040,"confidence":0.6791992,"speaker":"A"},{"text":"then","start":2226040,"end":2226320,"confidence":0.9941406,"speaker":"A"},{"text":"what","start":2227520,"end":2227760,"confidence":0.9873047,"speaker":"A"},{"text":"I","start":2227760,"end":2227880,"confidence":0.9980469,"speaker":"A"},{"text":"added,","start":2227880,"end":2228160,"confidence":0.99658203,"speaker":"A"},{"text":"which","start":2228480,"end":2228760,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":2228760,"end":2228920,"confidence":0.9995117,"speaker":"A"},{"text":"think","start":2228920,"end":2229040,"confidence":1,"speaker":"A"},{"text":"is","start":2229040,"end":2229160,"confidence":0.9975586,"speaker":"A"},{"text":"really","start":2229160,"end":2229320,"confidence":0.9995117,"speaker":"A"},{"text":"nice,","start":2229320,"end":2229600,"confidence":1,"speaker":"A"},{"text":"is","start":2229600,"end":2229800,"confidence":0.68310547,"speaker":"A"},{"text":"called","start":2229800,"end":2229960,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2229960,"end":2230120,"confidence":0.9975586,"speaker":"A"},{"text":"adaptive","start":2230120,"end":2230720,"confidence":0.9437256,"speaker":"A"},{"text":"token","start":2230720,"end":2231240,"confidence":0.84195966,"speaker":"A"},{"text":"manager.","start":2231240,"end":2231760,"confidence":0.9963379,"speaker":"A"},{"text":"And","start":2232240,"end":2232520,"confidence":0.6923828,"speaker":"A"},{"text":"the","start":2232520,"end":2232680,"confidence":0.9995117,"speaker":"A"},{"text":"idea","start":2232680,"end":2233000,"confidence":1,"speaker":"A"},{"text":"with","start":2233000,"end":2233160,"confidence":0.99609375,"speaker":"A"},{"text":"that","start":2233160,"end":2233360,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2233360,"end":2233600,"confidence":0.9975586,"speaker":"A"},{"text":"like","start":2233600,"end":2233880,"confidence":0.8354492,"speaker":"A"},{"text":"let's","start":2233880,"end":2234240,"confidence":0.9013672,"speaker":"A"},{"text":"say","start":2234240,"end":2234560,"confidence":0.9995117,"speaker":"A"},{"text":"you're","start":2236960,"end":2237360,"confidence":0.9977214,"speaker":"A"},{"text":"using","start":2237360,"end":2237520,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2237520,"end":2237720,"confidence":0.99902344,"speaker":"A"},{"text":"client","start":2237720,"end":2238160,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2238240,"end":2238560,"confidence":0.9926758,"speaker":"A"},{"text":"you","start":2238560,"end":2238880,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2238880,"end":2239280,"confidence":1,"speaker":"A"},{"text":"the","start":2239280,"end":2239560,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":2239560,"end":2239800,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":2239800,"end":2240480,"confidence":0.8408203,"speaker":"A"},{"text":"token","start":2240480,"end":2240920,"confidence":0.9995117,"speaker":"A"},{"text":"now","start":2240920,"end":2241200,"confidence":0.91308594,"speaker":"A"},{"text":"and","start":2241440,"end":2241720,"confidence":0.94628906,"speaker":"A"},{"text":"then","start":2241720,"end":2242000,"confidence":0.97216797,"speaker":"A"},{"text":"this","start":2242080,"end":2242360,"confidence":0.9975586,"speaker":"A"},{"text":"allows","start":2242360,"end":2242640,"confidence":1,"speaker":"A"},{"text":"you","start":2242640,"end":2242760,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2242760,"end":2242920,"confidence":0.9980469,"speaker":"A"},{"text":"upgrade","start":2242920,"end":2243440,"confidence":0.9767253,"speaker":"A"},{"text":"with","start":2243810,"end":2243970,"confidence":0.9770508,"speaker":"A"},{"text":"that","start":2243970,"end":2244170,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":2244170,"end":2244410,"confidence":0.998291,"speaker":"A"},{"text":"authentication","start":2244410,"end":2245090,"confidence":0.99938965,"speaker":"A"},{"text":"token","start":2245090,"end":2245450,"confidence":0.9991862,"speaker":"A"},{"text":"to","start":2245450,"end":2245610,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":2245610,"end":2245770,"confidence":1,"speaker":"A"},{"text":"private","start":2245770,"end":2245970,"confidence":1,"speaker":"A"},{"text":"database","start":2245970,"end":2246490,"confidence":0.9998372,"speaker":"A"},{"text":"and","start":2246490,"end":2246690,"confidence":0.99853516,"speaker":"A"},{"text":"have","start":2246690,"end":2246930,"confidence":0.99560547,"speaker":"A"},{"text":"access","start":2246930,"end":2247210,"confidence":1,"speaker":"A"},{"text":"to","start":2247210,"end":2247450,"confidence":0.9995117,"speaker":"A"},{"text":"that.","start":2247450,"end":2247730,"confidence":0.9995117,"speaker":"A"}]},{"text":"So and then all the, all the signing is done before you in miskit for the server to server because stuff that needs to be signed, etc. And it takes care of all that. All stuff that Claude was essentially able to decipher from the documentation.","start":2250530,"end":2270060,"confidence":0.97558594,"words":[{"text":"So","start":2250530,"end":2250850,"confidence":0.97558594,"speaker":"A"},{"text":"and","start":2250850,"end":2251050,"confidence":0.97558594,"speaker":"A"},{"text":"then","start":2251050,"end":2251210,"confidence":0.97753906,"speaker":"A"},{"text":"all","start":2251210,"end":2251490,"confidence":0.9658203,"speaker":"A"},{"text":"the,","start":2251490,"end":2251890,"confidence":0.9921875,"speaker":"A"},{"text":"all","start":2252690,"end":2252970,"confidence":0.9013672,"speaker":"A"},{"text":"the","start":2252970,"end":2253170,"confidence":0.99609375,"speaker":"A"},{"text":"signing","start":2253170,"end":2253610,"confidence":0.99658203,"speaker":"A"},{"text":"is","start":2253610,"end":2253770,"confidence":0.9926758,"speaker":"A"},{"text":"done","start":2253770,"end":2253970,"confidence":1,"speaker":"A"},{"text":"before","start":2253970,"end":2254290,"confidence":0.86816406,"speaker":"A"},{"text":"you","start":2254290,"end":2254610,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":2254610,"end":2254810,"confidence":0.9550781,"speaker":"A"},{"text":"miskit","start":2254810,"end":2255490,"confidence":0.8145752,"speaker":"A"},{"text":"for","start":2255650,"end":2256010,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2256010,"end":2256250,"confidence":0.99902344,"speaker":"A"},{"text":"server","start":2256250,"end":2256530,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2256530,"end":2256690,"confidence":0.8510742,"speaker":"A"},{"text":"server","start":2256690,"end":2257050,"confidence":0.9995117,"speaker":"A"},{"text":"because","start":2257050,"end":2257250,"confidence":0.9995117,"speaker":"A"},{"text":"stuff","start":2257250,"end":2257490,"confidence":0.9991862,"speaker":"A"},{"text":"that","start":2257490,"end":2257650,"confidence":0.68603516,"speaker":"A"},{"text":"needs","start":2257650,"end":2257850,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2257850,"end":2257970,"confidence":1,"speaker":"A"},{"text":"be","start":2257970,"end":2258090,"confidence":1,"speaker":"A"},{"text":"signed,","start":2258090,"end":2258330,"confidence":0.79589844,"speaker":"A"},{"text":"etc.","start":2258330,"end":2259010,"confidence":0.88311,"speaker":"A"},{"text":"And","start":2259570,"end":2259849,"confidence":0.99609375,"speaker":"A"},{"text":"it","start":2259849,"end":2260010,"confidence":0.99902344,"speaker":"A"},{"text":"takes","start":2260010,"end":2260250,"confidence":1,"speaker":"A"},{"text":"care","start":2260250,"end":2260410,"confidence":1,"speaker":"A"},{"text":"of","start":2260410,"end":2260610,"confidence":1,"speaker":"A"},{"text":"all","start":2260610,"end":2260850,"confidence":0.9951172,"speaker":"A"},{"text":"that.","start":2260850,"end":2261170,"confidence":0.99560547,"speaker":"A"},{"text":"All","start":2261570,"end":2261890,"confidence":0.9902344,"speaker":"A"},{"text":"stuff","start":2261890,"end":2262170,"confidence":0.9947917,"speaker":"A"},{"text":"that","start":2262170,"end":2262450,"confidence":0.99853516,"speaker":"A"},{"text":"Claude","start":2262690,"end":2263330,"confidence":0.7474365,"speaker":"A"},{"text":"was","start":2263330,"end":2263650,"confidence":0.9995117,"speaker":"A"},{"text":"essentially","start":2263650,"end":2264210,"confidence":0.9995117,"speaker":"A"},{"text":"able","start":2264210,"end":2264450,"confidence":0.9980469,"speaker":"A"},{"text":"to","start":2264450,"end":2264770,"confidence":1,"speaker":"A"},{"text":"decipher","start":2264850,"end":2265610,"confidence":0.99593097,"speaker":"A"},{"text":"from","start":2265610,"end":2265970,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2266610,"end":2267010,"confidence":0.99072266,"speaker":"A"},{"text":"documentation.","start":2269340,"end":2270060,"confidence":0.9116211,"speaker":"A"}]},{"text":"There's one more thing I wanted to show.","start":2272620,"end":2274300,"confidence":0.9972331,"words":[{"text":"There's","start":2272620,"end":2273020,"confidence":0.9972331,"speaker":"A"},{"text":"one","start":2273020,"end":2273140,"confidence":1,"speaker":"A"},{"text":"more","start":2273140,"end":2273300,"confidence":1,"speaker":"A"},{"text":"thing","start":2273300,"end":2273460,"confidence":1,"speaker":"A"},{"text":"I","start":2273460,"end":2273620,"confidence":0.9995117,"speaker":"A"},{"text":"wanted","start":2273620,"end":2273860,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":2273860,"end":2274020,"confidence":1,"speaker":"A"},{"text":"show.","start":2274020,"end":2274300,"confidence":0.99902344,"speaker":"A"}]},{"text":"If you want to hop in with a question while I pull something up, feel free.","start":2276380,"end":2280940,"confidence":0.9995117,"words":[{"text":"If","start":2276380,"end":2276660,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2276660,"end":2276780,"confidence":1,"speaker":"A"},{"text":"want","start":2276780,"end":2276860,"confidence":0.9921875,"speaker":"A"},{"text":"to","start":2276860,"end":2276980,"confidence":0.9995117,"speaker":"A"},{"text":"hop","start":2276980,"end":2277140,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":2277140,"end":2277300,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":2277300,"end":2277460,"confidence":1,"speaker":"A"},{"text":"a","start":2277460,"end":2277620,"confidence":0.9941406,"speaker":"A"},{"text":"question","start":2277620,"end":2277900,"confidence":1,"speaker":"A"},{"text":"while","start":2278380,"end":2278740,"confidence":0.9946289,"speaker":"A"},{"text":"I","start":2278740,"end":2279100,"confidence":0.99902344,"speaker":"A"},{"text":"pull","start":2279260,"end":2279620,"confidence":0.9995117,"speaker":"A"},{"text":"something","start":2279620,"end":2279860,"confidence":1,"speaker":"A"},{"text":"up,","start":2279860,"end":2280220,"confidence":0.99902344,"speaker":"A"},{"text":"feel","start":2280300,"end":2280620,"confidence":0.9995117,"speaker":"A"},{"text":"free.","start":2280620,"end":2280940,"confidence":1,"speaker":"A"}]},{"text":"No questions. Cool. So I'm going to show one last thing and that is how do we actually deploy this?","start":2301190,"end":2310310,"confidence":0.9892578,"words":[{"text":"No","start":2301190,"end":2301350,"confidence":0.9892578,"speaker":"A"},{"text":"questions.","start":2301350,"end":2301910,"confidence":0.9995117,"speaker":"A"},{"text":"Cool.","start":2303910,"end":2304390,"confidence":0.8347168,"speaker":"A"},{"text":"So","start":2304790,"end":2305030,"confidence":0.9921875,"speaker":"A"},{"text":"I'm","start":2305030,"end":2305190,"confidence":0.94905597,"speaker":"A"},{"text":"going","start":2305190,"end":2305270,"confidence":0.77441406,"speaker":"A"},{"text":"to","start":2305270,"end":2305350,"confidence":0.9980469,"speaker":"A"},{"text":"show","start":2305350,"end":2305510,"confidence":0.9975586,"speaker":"A"},{"text":"one","start":2305510,"end":2305710,"confidence":0.9995117,"speaker":"A"},{"text":"last","start":2305710,"end":2305950,"confidence":0.9995117,"speaker":"A"},{"text":"thing","start":2305950,"end":2306310,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2306950,"end":2307230,"confidence":0.9921875,"speaker":"A"},{"text":"that","start":2307230,"end":2307430,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2307430,"end":2307750,"confidence":0.99609375,"speaker":"A"},{"text":"how","start":2308230,"end":2308630,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":2308710,"end":2308990,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":2308990,"end":2309190,"confidence":1,"speaker":"A"},{"text":"actually","start":2309190,"end":2309470,"confidence":0.9970703,"speaker":"A"},{"text":"deploy","start":2309470,"end":2309990,"confidence":1,"speaker":"A"},{"text":"this?","start":2309990,"end":2310310,"confidence":0.9995117,"speaker":"A"}]},{"text":"Is this too big, too small? Looks okay. That looks good. Yeah, it looks good. Okay, cool.","start":2313350,"end":2320070,"confidence":0.9980469,"words":[{"text":"Is","start":2313350,"end":2313630,"confidence":0.9980469,"speaker":"A"},{"text":"this","start":2313630,"end":2313830,"confidence":0.9995117,"speaker":"A"},{"text":"too","start":2313830,"end":2314070,"confidence":0.9975586,"speaker":"A"},{"text":"big,","start":2314070,"end":2314350,"confidence":1,"speaker":"A"},{"text":"too","start":2314350,"end":2314590,"confidence":0.98779297,"speaker":"A"},{"text":"small?","start":2314590,"end":2314870,"confidence":0.99853516,"speaker":"A"},{"text":"Looks","start":2316150,"end":2316510,"confidence":0.8227539,"speaker":"A"},{"text":"okay.","start":2316510,"end":2316950,"confidence":0.9710286,"speaker":"A"},{"text":"That","start":2317590,"end":2317870,"confidence":0.97265625,"speaker":"C"},{"text":"looks","start":2317870,"end":2318150,"confidence":0.99902344,"speaker":"C"},{"text":"good.","start":2318150,"end":2318390,"confidence":0.9921875,"speaker":"C"},{"text":"Yeah,","start":2318710,"end":2319030,"confidence":0.992513,"speaker":"B"},{"text":"it","start":2319030,"end":2319110,"confidence":0.79003906,"speaker":"B"},{"text":"looks","start":2319110,"end":2319270,"confidence":0.99902344,"speaker":"B"},{"text":"good.","start":2319270,"end":2319430,"confidence":0.9951172,"speaker":"B"},{"text":"Okay,","start":2319430,"end":2319750,"confidence":0.9550781,"speaker":"A"},{"text":"cool.","start":2319750,"end":2320070,"confidence":0.99121094,"speaker":"A"}]},{"text":"So essentially what I've done is I'm using GitHub Actions. There's a way you can.","start":2323850,"end":2330410,"confidence":0.9604492,"words":[{"text":"So","start":2323850,"end":2324050,"confidence":0.9604492,"speaker":"A"},{"text":"essentially","start":2324050,"end":2324530,"confidence":0.9962158,"speaker":"A"},{"text":"what","start":2324530,"end":2324690,"confidence":0.9995117,"speaker":"A"},{"text":"I've","start":2324690,"end":2324930,"confidence":0.99886066,"speaker":"A"},{"text":"done","start":2324930,"end":2325210,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2325530,"end":2325930,"confidence":0.99365234,"speaker":"A"},{"text":"I'm","start":2326570,"end":2326930,"confidence":0.95214844,"speaker":"A"},{"text":"using","start":2326930,"end":2327210,"confidence":1,"speaker":"A"},{"text":"GitHub","start":2327370,"end":2327890,"confidence":0.9975586,"speaker":"A"},{"text":"Actions.","start":2327890,"end":2328490,"confidence":0.9992676,"speaker":"A"},{"text":"There's","start":2329290,"end":2329690,"confidence":0.9991862,"speaker":"A"},{"text":"a","start":2329690,"end":2329770,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":2329770,"end":2329930,"confidence":1,"speaker":"A"},{"text":"you","start":2329930,"end":2330130,"confidence":0.99902344,"speaker":"A"},{"text":"can.","start":2330130,"end":2330410,"confidence":0.99902344,"speaker":"A"}]},{"text":"This is all public by the way, so I will provide URLs in the Slack or something. Let's do this one. So this is a Swift package for Bushel. It's called Bushel Cloud. It pulls the stuff up from.","start":2333130,"end":2350660,"confidence":0.99902344,"words":[{"text":"This","start":2333130,"end":2333410,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2333410,"end":2333530,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":2333530,"end":2333770,"confidence":0.98876953,"speaker":"A"},{"text":"public","start":2334010,"end":2334370,"confidence":1,"speaker":"A"},{"text":"by","start":2334370,"end":2334570,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2334570,"end":2334690,"confidence":0.9995117,"speaker":"A"},{"text":"way,","start":2334690,"end":2334970,"confidence":1,"speaker":"A"},{"text":"so","start":2335050,"end":2335450,"confidence":0.9321289,"speaker":"A"},{"text":"I","start":2335850,"end":2336130,"confidence":0.99902344,"speaker":"A"},{"text":"will","start":2336130,"end":2336370,"confidence":0.86621094,"speaker":"A"},{"text":"provide","start":2336370,"end":2336689,"confidence":1,"speaker":"A"},{"text":"URLs","start":2336689,"end":2337330,"confidence":0.94067,"speaker":"A"},{"text":"in","start":2337330,"end":2337490,"confidence":0.98828125,"speaker":"A"},{"text":"the","start":2337490,"end":2337650,"confidence":0.9897461,"speaker":"A"},{"text":"Slack","start":2337650,"end":2337970,"confidence":0.998291,"speaker":"A"},{"text":"or","start":2337970,"end":2338170,"confidence":0.9970703,"speaker":"A"},{"text":"something.","start":2338170,"end":2338490,"confidence":0.9995117,"speaker":"A"},{"text":"Let's","start":2339450,"end":2339890,"confidence":0.99853516,"speaker":"A"},{"text":"do","start":2339890,"end":2340050,"confidence":0.9790039,"speaker":"A"},{"text":"this","start":2340050,"end":2340250,"confidence":0.9975586,"speaker":"A"},{"text":"one.","start":2340250,"end":2340570,"confidence":0.99316406,"speaker":"A"},{"text":"So","start":2342410,"end":2342810,"confidence":0.8173828,"speaker":"A"},{"text":"this","start":2343930,"end":2344210,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2344210,"end":2344370,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2344370,"end":2344530,"confidence":0.9765625,"speaker":"A"},{"text":"Swift","start":2344530,"end":2344810,"confidence":0.9226074,"speaker":"A"},{"text":"package","start":2344810,"end":2345370,"confidence":0.99768066,"speaker":"A"},{"text":"for","start":2347060,"end":2347220,"confidence":0.97998047,"speaker":"A"},{"text":"Bushel.","start":2347220,"end":2347860,"confidence":0.9685872,"speaker":"A"},{"text":"It's","start":2347860,"end":2348180,"confidence":0.9995117,"speaker":"A"},{"text":"called","start":2348180,"end":2348340,"confidence":0.99853516,"speaker":"A"},{"text":"Bushel","start":2348340,"end":2348780,"confidence":0.90283203,"speaker":"A"},{"text":"Cloud.","start":2348780,"end":2349180,"confidence":0.99658203,"speaker":"A"},{"text":"It","start":2349180,"end":2349420,"confidence":0.9995117,"speaker":"A"},{"text":"pulls","start":2349420,"end":2349700,"confidence":1,"speaker":"A"},{"text":"the","start":2349700,"end":2349820,"confidence":0.98828125,"speaker":"A"},{"text":"stuff","start":2349820,"end":2350060,"confidence":1,"speaker":"A"},{"text":"up","start":2350060,"end":2350300,"confidence":0.9995117,"speaker":"A"},{"text":"from.","start":2350300,"end":2350660,"confidence":0.9970703,"speaker":"A"}]},{"text":"Uses Miskit to go ahead and pull, get access to CloudKit and let me go back to the workflow. How familiar are you with GitHub workflows?","start":2351220,"end":2366580,"confidence":0.84887695,"words":[{"text":"Uses","start":2351220,"end":2351740,"confidence":0.84887695,"speaker":"A"},{"text":"Miskit","start":2351740,"end":2352340,"confidence":0.9329834,"speaker":"A"},{"text":"to","start":2353540,"end":2353820,"confidence":0.9941406,"speaker":"A"},{"text":"go","start":2353820,"end":2353980,"confidence":1,"speaker":"A"},{"text":"ahead","start":2353980,"end":2354260,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2354340,"end":2354740,"confidence":0.88720703,"speaker":"A"},{"text":"pull,","start":2356740,"end":2357220,"confidence":0.9621582,"speaker":"A"},{"text":"get","start":2357860,"end":2358140,"confidence":0.99902344,"speaker":"A"},{"text":"access","start":2358140,"end":2358380,"confidence":1,"speaker":"A"},{"text":"to","start":2358380,"end":2358700,"confidence":1,"speaker":"A"},{"text":"CloudKit","start":2358700,"end":2359460,"confidence":0.9325,"speaker":"A"},{"text":"and","start":2359940,"end":2360340,"confidence":0.98291016,"speaker":"A"},{"text":"let","start":2361060,"end":2361340,"confidence":0.99316406,"speaker":"A"},{"text":"me","start":2361340,"end":2361460,"confidence":1,"speaker":"A"},{"text":"go","start":2361460,"end":2361620,"confidence":0.9995117,"speaker":"A"},{"text":"back","start":2361620,"end":2361940,"confidence":1,"speaker":"A"},{"text":"to","start":2361940,"end":2362339,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2362339,"end":2362620,"confidence":1,"speaker":"A"},{"text":"workflow.","start":2362620,"end":2363300,"confidence":0.96276855,"speaker":"A"},{"text":"How","start":2364100,"end":2364420,"confidence":0.99853516,"speaker":"A"},{"text":"familiar","start":2364420,"end":2364860,"confidence":1,"speaker":"A"},{"text":"are","start":2364860,"end":2365020,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2365020,"end":2365180,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":2365180,"end":2365380,"confidence":1,"speaker":"A"},{"text":"GitHub","start":2365380,"end":2365860,"confidence":0.87939453,"speaker":"A"},{"text":"workflows?","start":2365860,"end":2366580,"confidence":0.9026367,"speaker":"A"}]},{"text":"Sadly not had the chance to work too deeply with them yet. Okay. Basically it's like for CI, but you can also set it up on a schedule. So I did that and then it runs the scheduled job and then I just execute.","start":2369860,"end":2386490,"confidence":0.99576825,"words":[{"text":"Sadly","start":2369860,"end":2370300,"confidence":0.99576825,"speaker":"C"},{"text":"not","start":2370300,"end":2370500,"confidence":0.9951172,"speaker":"C"},{"text":"had","start":2370500,"end":2370660,"confidence":0.9980469,"speaker":"C"},{"text":"the","start":2370660,"end":2370780,"confidence":0.99658203,"speaker":"C"},{"text":"chance","start":2370780,"end":2371020,"confidence":0.99975586,"speaker":"C"},{"text":"to","start":2371020,"end":2371180,"confidence":0.9995117,"speaker":"C"},{"text":"work","start":2371180,"end":2371460,"confidence":1,"speaker":"C"},{"text":"too","start":2371780,"end":2372060,"confidence":0.99560547,"speaker":"C"},{"text":"deeply","start":2372060,"end":2372380,"confidence":0.9991862,"speaker":"C"},{"text":"with","start":2372380,"end":2372500,"confidence":0.9995117,"speaker":"C"},{"text":"them","start":2372500,"end":2372660,"confidence":0.97021484,"speaker":"C"},{"text":"yet.","start":2372660,"end":2372980,"confidence":0.98291016,"speaker":"C"},{"text":"Okay.","start":2373690,"end":2374090,"confidence":0.9503581,"speaker":"A"},{"text":"Basically","start":2375130,"end":2375610,"confidence":0.9987793,"speaker":"A"},{"text":"it's","start":2375610,"end":2375850,"confidence":0.99934894,"speaker":"A"},{"text":"like","start":2375850,"end":2375970,"confidence":0.99072266,"speaker":"A"},{"text":"for","start":2375970,"end":2376170,"confidence":0.9448242,"speaker":"A"},{"text":"CI,","start":2376170,"end":2376610,"confidence":0.97021484,"speaker":"A"},{"text":"but","start":2376610,"end":2376810,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":2376810,"end":2376930,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2376930,"end":2377050,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":2377050,"end":2377250,"confidence":0.9995117,"speaker":"A"},{"text":"set","start":2377250,"end":2377490,"confidence":1,"speaker":"A"},{"text":"it","start":2377490,"end":2377610,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":2377610,"end":2377730,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":2377730,"end":2377890,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2377890,"end":2378050,"confidence":0.9980469,"speaker":"A"},{"text":"schedule.","start":2378050,"end":2378570,"confidence":0.8905029,"speaker":"A"},{"text":"So","start":2378890,"end":2379170,"confidence":0.9941406,"speaker":"A"},{"text":"I","start":2379170,"end":2379330,"confidence":1,"speaker":"A"},{"text":"did","start":2379330,"end":2379530,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":2379530,"end":2379850,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2381290,"end":2381570,"confidence":0.9902344,"speaker":"A"},{"text":"then","start":2381570,"end":2381850,"confidence":0.9980469,"speaker":"A"},{"text":"it","start":2382890,"end":2383170,"confidence":0.99853516,"speaker":"A"},{"text":"runs","start":2383170,"end":2383490,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":2383490,"end":2383610,"confidence":0.6640625,"speaker":"A"},{"text":"scheduled","start":2383610,"end":2384090,"confidence":0.89404297,"speaker":"A"},{"text":"job","start":2384090,"end":2384410,"confidence":1,"speaker":"A"},{"text":"and","start":2384810,"end":2385090,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":2385090,"end":2385250,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2385250,"end":2385450,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":2385450,"end":2385730,"confidence":0.9995117,"speaker":"A"},{"text":"execute.","start":2385730,"end":2386490,"confidence":0.97875977,"speaker":"A"}]},{"text":"So then this was refactored over here into an action.","start":2390650,"end":2395210,"confidence":0.9941406,"words":[{"text":"So","start":2390650,"end":2390930,"confidence":0.9941406,"speaker":"A"},{"text":"then","start":2390930,"end":2391170,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":2391170,"end":2391410,"confidence":1,"speaker":"A"},{"text":"was","start":2391410,"end":2391610,"confidence":0.9995117,"speaker":"A"},{"text":"refactored","start":2391610,"end":2392490,"confidence":0.99283856,"speaker":"A"},{"text":"over","start":2393290,"end":2393690,"confidence":0.99560547,"speaker":"A"},{"text":"here","start":2393690,"end":2394090,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":2394330,"end":2394650,"confidence":0.9741211,"speaker":"A"},{"text":"an","start":2394650,"end":2394890,"confidence":0.99902344,"speaker":"A"},{"text":"action.","start":2394890,"end":2395210,"confidence":0.9995117,"speaker":"A"}]},{"text":"There we go. And I have all sorts of stuff here for like this is generic essentially, but all these, the environment, etc. These are all passed from that workflow into here. These are basically either API keys or the information that I need for accessing Cloud, the public, public database. Right.","start":2397770,"end":2426080,"confidence":0.89990234,"words":[{"text":"There","start":2397770,"end":2398090,"confidence":0.89990234,"speaker":"A"},{"text":"we","start":2398090,"end":2398250,"confidence":0.99853516,"speaker":"A"},{"text":"go.","start":2398250,"end":2398490,"confidence":0.99853516,"speaker":"A"},{"text":"And","start":2399540,"end":2399780,"confidence":0.9848633,"speaker":"A"},{"text":"I","start":2401140,"end":2401420,"confidence":0.99658203,"speaker":"A"},{"text":"have","start":2401420,"end":2401580,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2401580,"end":2401740,"confidence":0.9995117,"speaker":"A"},{"text":"sorts","start":2401740,"end":2402020,"confidence":0.890625,"speaker":"A"},{"text":"of","start":2402020,"end":2402180,"confidence":1,"speaker":"A"},{"text":"stuff","start":2402180,"end":2402380,"confidence":1,"speaker":"A"},{"text":"here","start":2402380,"end":2402660,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":2403060,"end":2403460,"confidence":0.9863281,"speaker":"A"},{"text":"like","start":2405380,"end":2405780,"confidence":0.97021484,"speaker":"A"},{"text":"this","start":2406660,"end":2406940,"confidence":0.9975586,"speaker":"A"},{"text":"is","start":2406940,"end":2407100,"confidence":0.99902344,"speaker":"A"},{"text":"generic","start":2407100,"end":2407700,"confidence":1,"speaker":"A"},{"text":"essentially,","start":2407700,"end":2408420,"confidence":0.9996338,"speaker":"A"},{"text":"but","start":2408500,"end":2408900,"confidence":0.9941406,"speaker":"A"},{"text":"all","start":2410020,"end":2410300,"confidence":0.98828125,"speaker":"A"},{"text":"these,","start":2410300,"end":2410580,"confidence":0.9868164,"speaker":"A"},{"text":"the","start":2410820,"end":2411140,"confidence":0.9223633,"speaker":"A"},{"text":"environment,","start":2411140,"end":2411460,"confidence":1,"speaker":"A"},{"text":"etc.","start":2411700,"end":2412500,"confidence":0.975,"speaker":"A"},{"text":"These","start":2413140,"end":2413420,"confidence":0.9995117,"speaker":"A"},{"text":"are","start":2413420,"end":2413540,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2413540,"end":2413700,"confidence":0.99853516,"speaker":"A"},{"text":"passed","start":2413700,"end":2414060,"confidence":0.93310547,"speaker":"A"},{"text":"from","start":2414060,"end":2414220,"confidence":1,"speaker":"A"},{"text":"that","start":2414220,"end":2414420,"confidence":0.99902344,"speaker":"A"},{"text":"workflow","start":2414420,"end":2414980,"confidence":0.9741211,"speaker":"A"},{"text":"into","start":2414980,"end":2415260,"confidence":0.99609375,"speaker":"A"},{"text":"here.","start":2415260,"end":2415620,"confidence":0.99902344,"speaker":"A"},{"text":"These","start":2415940,"end":2416220,"confidence":0.9975586,"speaker":"A"},{"text":"are","start":2416220,"end":2416380,"confidence":0.9995117,"speaker":"A"},{"text":"basically","start":2416380,"end":2416820,"confidence":0.9992676,"speaker":"A"},{"text":"either","start":2416820,"end":2417180,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":2417180,"end":2417620,"confidence":0.85180664,"speaker":"A"},{"text":"keys","start":2417620,"end":2417980,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":2417980,"end":2418180,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2418180,"end":2418420,"confidence":0.99902344,"speaker":"A"},{"text":"information","start":2418420,"end":2418740,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":2418820,"end":2419100,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2419100,"end":2419260,"confidence":1,"speaker":"A"},{"text":"need","start":2419260,"end":2419540,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":2419620,"end":2420020,"confidence":0.9995117,"speaker":"A"},{"text":"accessing","start":2420500,"end":2421100,"confidence":0.9953613,"speaker":"A"},{"text":"Cloud,","start":2421100,"end":2421460,"confidence":0.9243164,"speaker":"A"},{"text":"the","start":2421460,"end":2421780,"confidence":0.8491211,"speaker":"A"},{"text":"public,","start":2421780,"end":2422100,"confidence":0.765625,"speaker":"A"},{"text":"public","start":2424020,"end":2424380,"confidence":0.9995117,"speaker":"A"},{"text":"database.","start":2424380,"end":2425060,"confidence":0.99869794,"speaker":"A"},{"text":"Right.","start":2425840,"end":2426080,"confidence":0.9008789,"speaker":"A"}]},{"text":"And then I already pre built the binary. So we already have that. We're running this on Ubuntu because it's the default. Look at it. If there is no binary, it goes ahead and builds the binary for me.","start":2426480,"end":2443840,"confidence":0.9794922,"words":[{"text":"And","start":2426480,"end":2426760,"confidence":0.9794922,"speaker":"A"},{"text":"then","start":2426760,"end":2427040,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":2427840,"end":2428120,"confidence":0.96435547,"speaker":"A"},{"text":"already","start":2428120,"end":2428360,"confidence":0.99902344,"speaker":"A"},{"text":"pre","start":2428360,"end":2428680,"confidence":0.99853516,"speaker":"A"},{"text":"built","start":2428680,"end":2429200,"confidence":0.8404948,"speaker":"A"},{"text":"the","start":2429760,"end":2430160,"confidence":0.9970703,"speaker":"A"},{"text":"binary.","start":2430160,"end":2430880,"confidence":0.9977214,"speaker":"A"},{"text":"So","start":2431120,"end":2431520,"confidence":0.99316406,"speaker":"A"},{"text":"we","start":2431600,"end":2431880,"confidence":0.9995117,"speaker":"A"},{"text":"already","start":2431880,"end":2432040,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2432040,"end":2432200,"confidence":0.99902344,"speaker":"A"},{"text":"that.","start":2432200,"end":2432360,"confidence":1,"speaker":"A"},{"text":"We're","start":2432360,"end":2432600,"confidence":0.9973958,"speaker":"A"},{"text":"running","start":2432600,"end":2432840,"confidence":1,"speaker":"A"},{"text":"this","start":2432840,"end":2433120,"confidence":0.99902344,"speaker":"A"},{"text":"on","start":2433200,"end":2433600,"confidence":0.9975586,"speaker":"A"},{"text":"Ubuntu","start":2434880,"end":2435720,"confidence":0.93408203,"speaker":"A"},{"text":"because","start":2435720,"end":2435960,"confidence":0.94970703,"speaker":"A"},{"text":"it's","start":2435960,"end":2436160,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2436160,"end":2436280,"confidence":0.8647461,"speaker":"A"},{"text":"default.","start":2436280,"end":2436800,"confidence":0.9998779,"speaker":"A"},{"text":"Look","start":2437200,"end":2437480,"confidence":0.9970703,"speaker":"A"},{"text":"at","start":2437480,"end":2437640,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":2437640,"end":2437920,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":2439200,"end":2439600,"confidence":0.9980469,"speaker":"A"},{"text":"there","start":2439920,"end":2440280,"confidence":1,"speaker":"A"},{"text":"is","start":2440280,"end":2440560,"confidence":0.9995117,"speaker":"A"},{"text":"no","start":2440560,"end":2440880,"confidence":0.9970703,"speaker":"A"},{"text":"binary,","start":2440960,"end":2441639,"confidence":0.9977214,"speaker":"A"},{"text":"it","start":2441639,"end":2441840,"confidence":0.9736328,"speaker":"A"},{"text":"goes","start":2441840,"end":2442000,"confidence":1,"speaker":"A"},{"text":"ahead","start":2442000,"end":2442120,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2442120,"end":2442320,"confidence":1,"speaker":"A"},{"text":"builds","start":2442320,"end":2442680,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":2442680,"end":2442800,"confidence":1,"speaker":"A"},{"text":"binary","start":2442800,"end":2443280,"confidence":0.9991862,"speaker":"A"},{"text":"for","start":2443280,"end":2443520,"confidence":0.99853516,"speaker":"A"},{"text":"me.","start":2443520,"end":2443840,"confidence":0.9995117,"speaker":"A"}]},{"text":"So that's what this is doing. And then we make sure the binary works. We make, we make it executable, we validate, make sure all the API secrets are there. We then go ahead and this validates the pim. But essentially this is the fun part.","start":2444000,"end":2462370,"confidence":0.95166016,"words":[{"text":"So","start":2444000,"end":2444240,"confidence":0.95166016,"speaker":"A"},{"text":"that's","start":2444240,"end":2444400,"confidence":0.9991862,"speaker":"A"},{"text":"what","start":2444400,"end":2444520,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2444520,"end":2444680,"confidence":1,"speaker":"A"},{"text":"is","start":2444680,"end":2444880,"confidence":1,"speaker":"A"},{"text":"doing.","start":2444880,"end":2445200,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":2447120,"end":2447440,"confidence":0.88671875,"speaker":"A"},{"text":"then","start":2447440,"end":2447760,"confidence":0.99902344,"speaker":"A"},{"text":"we","start":2448800,"end":2449080,"confidence":0.9995117,"speaker":"A"},{"text":"make","start":2449080,"end":2449280,"confidence":0.7973633,"speaker":"A"},{"text":"sure","start":2449280,"end":2449480,"confidence":1,"speaker":"A"},{"text":"the","start":2449480,"end":2449640,"confidence":0.9941406,"speaker":"A"},{"text":"binary","start":2449640,"end":2450080,"confidence":0.92838544,"speaker":"A"},{"text":"works.","start":2450080,"end":2450640,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":2450880,"end":2451120,"confidence":0.41552734,"speaker":"A"},{"text":"make,","start":2451120,"end":2451180,"confidence":0.6088867,"speaker":"A"},{"text":"we","start":2451250,"end":2451330,"confidence":0.6176758,"speaker":"A"},{"text":"make","start":2451330,"end":2451450,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":2451450,"end":2451610,"confidence":0.9550781,"speaker":"A"},{"text":"executable,","start":2451610,"end":2452210,"confidence":0.9968262,"speaker":"A"},{"text":"we","start":2452290,"end":2452650,"confidence":0.99658203,"speaker":"A"},{"text":"validate,","start":2452650,"end":2453290,"confidence":0.9996745,"speaker":"A"},{"text":"make","start":2453290,"end":2453530,"confidence":0.9951172,"speaker":"A"},{"text":"sure","start":2453530,"end":2453730,"confidence":1,"speaker":"A"},{"text":"all","start":2453730,"end":2454050,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2454050,"end":2454450,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":2455010,"end":2455570,"confidence":0.9987793,"speaker":"A"},{"text":"secrets","start":2455570,"end":2456050,"confidence":0.98339844,"speaker":"A"},{"text":"are","start":2456050,"end":2456250,"confidence":0.99902344,"speaker":"A"},{"text":"there.","start":2456250,"end":2456530,"confidence":0.99902344,"speaker":"A"},{"text":"We","start":2457650,"end":2457970,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":2457970,"end":2458210,"confidence":0.99658203,"speaker":"A"},{"text":"go","start":2458210,"end":2458410,"confidence":0.99853516,"speaker":"A"},{"text":"ahead","start":2458410,"end":2458690,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2458930,"end":2459290,"confidence":0.9921875,"speaker":"A"},{"text":"this","start":2459290,"end":2459530,"confidence":0.9863281,"speaker":"A"},{"text":"validates","start":2459530,"end":2460010,"confidence":0.99690753,"speaker":"A"},{"text":"the","start":2460010,"end":2460170,"confidence":0.99902344,"speaker":"A"},{"text":"pim.","start":2460170,"end":2460530,"confidence":0.8864746,"speaker":"A"},{"text":"But","start":2460690,"end":2460970,"confidence":0.99853516,"speaker":"A"},{"text":"essentially","start":2460970,"end":2461370,"confidence":0.9954834,"speaker":"A"},{"text":"this","start":2461370,"end":2461530,"confidence":0.9902344,"speaker":"A"},{"text":"is","start":2461530,"end":2461650,"confidence":0.9814453,"speaker":"A"},{"text":"the","start":2461650,"end":2461770,"confidence":0.8173828,"speaker":"A"},{"text":"fun","start":2461770,"end":2462010,"confidence":0.9980469,"speaker":"A"},{"text":"part.","start":2462010,"end":2462370,"confidence":0.9995117,"speaker":"A"}]},{"text":"We go ahead, we have all our inputs for the private key, the key id, environment, container id. And then I use Virtual Buddy for signing verification. And.","start":2463410,"end":2474450,"confidence":0.9995117,"words":[{"text":"We","start":2463410,"end":2463690,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":2463690,"end":2463810,"confidence":0.9995117,"speaker":"A"},{"text":"ahead,","start":2463810,"end":2464050,"confidence":0.99902344,"speaker":"A"},{"text":"we","start":2464050,"end":2464330,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":2464330,"end":2464610,"confidence":0.99902344,"speaker":"A"},{"text":"all","start":2464930,"end":2465290,"confidence":0.99853516,"speaker":"A"},{"text":"our","start":2465290,"end":2465530,"confidence":0.99365234,"speaker":"A"},{"text":"inputs","start":2465530,"end":2466010,"confidence":0.88171387,"speaker":"A"},{"text":"for","start":2466010,"end":2466170,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2466170,"end":2466290,"confidence":1,"speaker":"A"},{"text":"private","start":2466290,"end":2466490,"confidence":0.99902344,"speaker":"A"},{"text":"key,","start":2466490,"end":2466770,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2466770,"end":2467089,"confidence":0.9277344,"speaker":"A"},{"text":"key","start":2467089,"end":2467410,"confidence":0.98779297,"speaker":"A"},{"text":"id,","start":2467410,"end":2467730,"confidence":0.97021484,"speaker":"A"},{"text":"environment,","start":2467810,"end":2468210,"confidence":0.99902344,"speaker":"A"},{"text":"container","start":2468690,"end":2469290,"confidence":0.99902344,"speaker":"A"},{"text":"id.","start":2469290,"end":2469570,"confidence":0.99609375,"speaker":"A"},{"text":"And","start":2470610,"end":2470890,"confidence":0.9707031,"speaker":"A"},{"text":"then","start":2470890,"end":2471050,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2471050,"end":2471170,"confidence":0.99902344,"speaker":"A"},{"text":"use","start":2471170,"end":2471370,"confidence":0.99658203,"speaker":"A"},{"text":"Virtual","start":2471370,"end":2471770,"confidence":0.9996338,"speaker":"A"},{"text":"Buddy","start":2471770,"end":2472090,"confidence":0.98583984,"speaker":"A"},{"text":"for","start":2472090,"end":2472250,"confidence":0.99902344,"speaker":"A"},{"text":"signing","start":2472250,"end":2472650,"confidence":0.9938965,"speaker":"A"},{"text":"verification.","start":2472650,"end":2473410,"confidence":0.99990237,"speaker":"A"},{"text":"And.","start":2474050,"end":2474450,"confidence":0.93603516,"speaker":"A"}]},{"text":"It then goes in and it runs the sync and then we'll go in. Basically it pulls from several websites information about macrosos, restore images and checks whether they're signed. And then it goes ahead and it adds those to the database. And then what this does is it exports the information in a run. Let's, let's take a look, see if I have one.","start":2478460,"end":2504020,"confidence":0.9707031,"words":[{"text":"It","start":2478460,"end":2478580,"confidence":0.9707031,"speaker":"A"},{"text":"then","start":2478580,"end":2478740,"confidence":0.9980469,"speaker":"A"},{"text":"goes","start":2478740,"end":2479060,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":2479060,"end":2479220,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2479220,"end":2479500,"confidence":0.8173828,"speaker":"A"},{"text":"it","start":2479900,"end":2480300,"confidence":0.99560547,"speaker":"A"},{"text":"runs","start":2481260,"end":2481740,"confidence":1,"speaker":"A"},{"text":"the","start":2481740,"end":2481940,"confidence":0.9995117,"speaker":"A"},{"text":"sync","start":2481940,"end":2482380,"confidence":0.9733073,"speaker":"A"},{"text":"and","start":2483500,"end":2483780,"confidence":0.96435547,"speaker":"A"},{"text":"then","start":2483780,"end":2484060,"confidence":0.97753906,"speaker":"A"},{"text":"we'll","start":2484860,"end":2485220,"confidence":0.8601888,"speaker":"A"},{"text":"go","start":2485220,"end":2485380,"confidence":0.99902344,"speaker":"A"},{"text":"in.","start":2485380,"end":2485660,"confidence":0.9980469,"speaker":"A"},{"text":"Basically","start":2485980,"end":2486460,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2486460,"end":2486620,"confidence":0.95996094,"speaker":"A"},{"text":"pulls","start":2486620,"end":2486900,"confidence":0.99902344,"speaker":"A"},{"text":"from","start":2486900,"end":2487060,"confidence":1,"speaker":"A"},{"text":"several","start":2487060,"end":2487340,"confidence":0.9995117,"speaker":"A"},{"text":"websites","start":2487340,"end":2488140,"confidence":0.99658203,"speaker":"A"},{"text":"information","start":2489100,"end":2489500,"confidence":1,"speaker":"A"},{"text":"about","start":2489580,"end":2489900,"confidence":0.9995117,"speaker":"A"},{"text":"macrosos,","start":2489900,"end":2490500,"confidence":0.85645,"speaker":"A"},{"text":"restore","start":2490500,"end":2490940,"confidence":0.85498047,"speaker":"A"},{"text":"images","start":2490940,"end":2491380,"confidence":0.998291,"speaker":"A"},{"text":"and","start":2491380,"end":2491620,"confidence":0.9980469,"speaker":"A"},{"text":"checks","start":2491620,"end":2491940,"confidence":0.9996745,"speaker":"A"},{"text":"whether","start":2491940,"end":2492100,"confidence":0.99902344,"speaker":"A"},{"text":"they're","start":2492100,"end":2492380,"confidence":0.98030597,"speaker":"A"},{"text":"signed.","start":2492380,"end":2492939,"confidence":0.80981445,"speaker":"A"},{"text":"And","start":2493340,"end":2493620,"confidence":0.94970703,"speaker":"A"},{"text":"then","start":2493620,"end":2493780,"confidence":0.9970703,"speaker":"A"},{"text":"it","start":2493780,"end":2493940,"confidence":1,"speaker":"A"},{"text":"goes","start":2493940,"end":2494140,"confidence":1,"speaker":"A"},{"text":"ahead","start":2494140,"end":2494340,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2494340,"end":2494700,"confidence":0.53125,"speaker":"A"},{"text":"it","start":2494780,"end":2495180,"confidence":0.86621094,"speaker":"A"},{"text":"adds","start":2496380,"end":2496900,"confidence":0.99853516,"speaker":"A"},{"text":"those","start":2496900,"end":2497180,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2497260,"end":2497540,"confidence":1,"speaker":"A"},{"text":"the","start":2497540,"end":2497660,"confidence":1,"speaker":"A"},{"text":"database.","start":2497660,"end":2498260,"confidence":0.9998372,"speaker":"A"},{"text":"And","start":2498260,"end":2498500,"confidence":0.9238281,"speaker":"A"},{"text":"then","start":2498500,"end":2498700,"confidence":0.9902344,"speaker":"A"},{"text":"what","start":2498700,"end":2498900,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2498900,"end":2499060,"confidence":1,"speaker":"A"},{"text":"does","start":2499060,"end":2499260,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2499260,"end":2499460,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":2499460,"end":2499620,"confidence":0.86279297,"speaker":"A"},{"text":"exports","start":2499620,"end":2500140,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2500620,"end":2500940,"confidence":0.99560547,"speaker":"A"},{"text":"information","start":2500940,"end":2501260,"confidence":1,"speaker":"A"},{"text":"in","start":2501500,"end":2501780,"confidence":0.9946289,"speaker":"A"},{"text":"a","start":2501780,"end":2501900,"confidence":0.98046875,"speaker":"A"},{"text":"run.","start":2501900,"end":2502100,"confidence":0.9926758,"speaker":"A"},{"text":"Let's,","start":2502100,"end":2502460,"confidence":0.7273763,"speaker":"A"},{"text":"let's","start":2502460,"end":2502700,"confidence":0.8728841,"speaker":"A"},{"text":"take","start":2502700,"end":2502820,"confidence":0.9921875,"speaker":"A"},{"text":"a","start":2502820,"end":2502940,"confidence":1,"speaker":"A"},{"text":"look,","start":2502940,"end":2503140,"confidence":0.9995117,"speaker":"A"},{"text":"see","start":2503140,"end":2503380,"confidence":0.99902344,"speaker":"A"},{"text":"if","start":2503380,"end":2503500,"confidence":1,"speaker":"A"},{"text":"I","start":2503500,"end":2503580,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2503580,"end":2503740,"confidence":0.9995117,"speaker":"A"},{"text":"one.","start":2503740,"end":2504020,"confidence":0.9863281,"speaker":"A"}]},{"text":"I can show you. Oh, there's one scheduled.","start":2504020,"end":2507420,"confidence":0.99316406,"words":[{"text":"I","start":2504020,"end":2504260,"confidence":0.99316406,"speaker":"A"},{"text":"can","start":2504260,"end":2504420,"confidence":0.9458008,"speaker":"A"},{"text":"show","start":2504420,"end":2504580,"confidence":0.9995117,"speaker":"A"},{"text":"you.","start":2504580,"end":2504860,"confidence":0.9970703,"speaker":"A"},{"text":"Oh,","start":2505980,"end":2506180,"confidence":0.8977051,"speaker":"A"},{"text":"there's","start":2506180,"end":2506460,"confidence":0.91503906,"speaker":"A"},{"text":"one","start":2506460,"end":2506700,"confidence":0.99853516,"speaker":"A"},{"text":"scheduled.","start":2506700,"end":2507420,"confidence":0.97436523,"speaker":"A"}]},{"text":"Yeah, here we go. So there's 57 new restore images created, 177 updated. 234 Total. No operations failed. I also store Xcode versions and Swift versions.","start":2510060,"end":2525900,"confidence":0.97347003,"words":[{"text":"Yeah,","start":2510060,"end":2510460,"confidence":0.97347003,"speaker":"A"},{"text":"here","start":2510460,"end":2510660,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2510660,"end":2510780,"confidence":1,"speaker":"A"},{"text":"go.","start":2510780,"end":2511020,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2511260,"end":2511660,"confidence":0.8173828,"speaker":"A"},{"text":"there's","start":2512060,"end":2512700,"confidence":0.9090169,"speaker":"A"},{"text":"57","start":2513100,"end":2513700,"confidence":0.99829,"speaker":"A"},{"text":"new","start":2513700,"end":2514060,"confidence":0.98291016,"speaker":"A"},{"text":"restore","start":2514060,"end":2514580,"confidence":0.84936523,"speaker":"A"},{"text":"images","start":2514580,"end":2514980,"confidence":0.9980469,"speaker":"A"},{"text":"created,","start":2514980,"end":2515580,"confidence":0.9970703,"speaker":"A"},{"text":"177","start":2516300,"end":2517500,"confidence":0.95771,"speaker":"A"},{"text":"updated.","start":2517660,"end":2518300,"confidence":0.9980469,"speaker":"A"},{"text":"234","start":2518780,"end":2519900,"confidence":0.93447,"speaker":"A"},{"text":"total.","start":2519980,"end":2520380,"confidence":0.9995117,"speaker":"A"},{"text":"No","start":2521420,"end":2521740,"confidence":0.9970703,"speaker":"A"},{"text":"operations","start":2521740,"end":2522300,"confidence":0.9987793,"speaker":"A"},{"text":"failed.","start":2522380,"end":2523020,"confidence":0.9992676,"speaker":"A"},{"text":"I","start":2523100,"end":2523380,"confidence":0.9916992,"speaker":"A"},{"text":"also","start":2523380,"end":2523580,"confidence":0.99902344,"speaker":"A"},{"text":"store","start":2523580,"end":2523900,"confidence":0.77490234,"speaker":"A"},{"text":"Xcode","start":2523900,"end":2524340,"confidence":0.89245605,"speaker":"A"},{"text":"versions","start":2524340,"end":2524700,"confidence":0.9970703,"speaker":"A"},{"text":"and","start":2524700,"end":2524980,"confidence":0.9370117,"speaker":"A"},{"text":"Swift","start":2524980,"end":2525420,"confidence":0.9921875,"speaker":"A"},{"text":"versions.","start":2525420,"end":2525900,"confidence":0.9975586,"speaker":"A"}]},{"text":"Those get stored as well. Had to rebuild it, but here is the results. I'm not going to pull that up, but it's essentially updated my CloudKit database and that's all in the public database. And then maybe even by the time I present this, I'll have a working example in Bushel with that example working, which would be awesome. Celestra, same idea.","start":2526780,"end":2554870,"confidence":0.99853516,"words":[{"text":"Those","start":2526780,"end":2527100,"confidence":0.99853516,"speaker":"A"},{"text":"get","start":2527100,"end":2527300,"confidence":0.99902344,"speaker":"A"},{"text":"stored","start":2527300,"end":2527620,"confidence":0.99853516,"speaker":"A"},{"text":"as","start":2527620,"end":2527780,"confidence":0.9995117,"speaker":"A"},{"text":"well.","start":2527780,"end":2528060,"confidence":0.9995117,"speaker":"A"},{"text":"Had","start":2529420,"end":2529700,"confidence":0.89697266,"speaker":"A"},{"text":"to","start":2529700,"end":2529860,"confidence":0.9736328,"speaker":"A"},{"text":"rebuild","start":2529860,"end":2530180,"confidence":0.9995117,"speaker":"A"},{"text":"it,","start":2530180,"end":2530460,"confidence":0.9975586,"speaker":"A"},{"text":"but","start":2530630,"end":2530790,"confidence":0.99902344,"speaker":"A"},{"text":"here","start":2530790,"end":2531070,"confidence":1,"speaker":"A"},{"text":"is","start":2531070,"end":2531310,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2531310,"end":2531510,"confidence":1,"speaker":"A"},{"text":"results.","start":2531510,"end":2531830,"confidence":0.98046875,"speaker":"A"},{"text":"I'm","start":2533750,"end":2534070,"confidence":0.9995117,"speaker":"A"},{"text":"not","start":2534070,"end":2534190,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":2534190,"end":2534310,"confidence":0.9140625,"speaker":"A"},{"text":"to","start":2534310,"end":2534390,"confidence":0.9995117,"speaker":"A"},{"text":"pull","start":2534390,"end":2534590,"confidence":0.99975586,"speaker":"A"},{"text":"that","start":2534590,"end":2534750,"confidence":0.99853516,"speaker":"A"},{"text":"up,","start":2534750,"end":2535030,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":2535830,"end":2536110,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":2536110,"end":2536350,"confidence":0.9944661,"speaker":"A"},{"text":"essentially","start":2536350,"end":2536950,"confidence":0.9980469,"speaker":"A"},{"text":"updated","start":2537270,"end":2537750,"confidence":0.99853516,"speaker":"A"},{"text":"my","start":2537750,"end":2537990,"confidence":0.99609375,"speaker":"A"},{"text":"CloudKit","start":2537990,"end":2538710,"confidence":0.9953613,"speaker":"A"},{"text":"database","start":2538790,"end":2539510,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2542070,"end":2542470,"confidence":0.99658203,"speaker":"A"},{"text":"that's","start":2542550,"end":2542950,"confidence":0.9998372,"speaker":"A"},{"text":"all","start":2542950,"end":2543070,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":2543070,"end":2543190,"confidence":0.9892578,"speaker":"A"},{"text":"the","start":2543190,"end":2543310,"confidence":0.99902344,"speaker":"A"},{"text":"public","start":2543310,"end":2543510,"confidence":1,"speaker":"A"},{"text":"database.","start":2543510,"end":2544030,"confidence":0.9991862,"speaker":"A"},{"text":"And","start":2544030,"end":2544150,"confidence":0.9980469,"speaker":"A"},{"text":"then","start":2544150,"end":2544390,"confidence":0.9980469,"speaker":"A"},{"text":"maybe","start":2545110,"end":2545470,"confidence":0.99975586,"speaker":"A"},{"text":"even","start":2545470,"end":2545670,"confidence":0.9995117,"speaker":"A"},{"text":"by","start":2545670,"end":2545870,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2545870,"end":2546030,"confidence":0.9995117,"speaker":"A"},{"text":"time","start":2546030,"end":2546190,"confidence":1,"speaker":"A"},{"text":"I","start":2546190,"end":2546310,"confidence":0.99560547,"speaker":"A"},{"text":"present","start":2546310,"end":2546550,"confidence":0.9995117,"speaker":"A"},{"text":"this,","start":2546550,"end":2546869,"confidence":0.9995117,"speaker":"A"},{"text":"I'll","start":2546869,"end":2547110,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2547110,"end":2547310,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":2547310,"end":2547550,"confidence":0.97314453,"speaker":"A"},{"text":"working","start":2547550,"end":2547830,"confidence":0.99902344,"speaker":"A"},{"text":"example","start":2547830,"end":2548350,"confidence":0.9814453,"speaker":"A"},{"text":"in","start":2548350,"end":2548510,"confidence":0.7578125,"speaker":"A"},{"text":"Bushel","start":2548510,"end":2548950,"confidence":0.9241536,"speaker":"A"},{"text":"with","start":2548950,"end":2549150,"confidence":1,"speaker":"A"},{"text":"that","start":2549150,"end":2549390,"confidence":0.9975586,"speaker":"A"},{"text":"example","start":2549390,"end":2549910,"confidence":0.9869792,"speaker":"A"},{"text":"working,","start":2549910,"end":2550230,"confidence":0.99902344,"speaker":"A"},{"text":"which","start":2550630,"end":2550910,"confidence":0.93310547,"speaker":"A"},{"text":"would","start":2550910,"end":2551070,"confidence":0.9277344,"speaker":"A"},{"text":"be","start":2551070,"end":2551230,"confidence":0.9995117,"speaker":"A"},{"text":"awesome.","start":2551230,"end":2551670,"confidence":0.99886066,"speaker":"A"},{"text":"Celestra,","start":2552870,"end":2553750,"confidence":0.7898763,"speaker":"A"},{"text":"same","start":2553990,"end":2554310,"confidence":0.99853516,"speaker":"A"},{"text":"idea.","start":2554310,"end":2554870,"confidence":0.998291,"speaker":"A"}]},{"text":"So this looks like it was a RSS update. We get the workflow file and. Oh, sorry, I should point out, because you're probably wondering where is all these. The stuff all these secrets stored? Yes, they are stored in Actions secrets right here.","start":2555030,"end":2573070,"confidence":0.9970703,"words":[{"text":"So","start":2555030,"end":2555310,"confidence":0.9970703,"speaker":"A"},{"text":"this","start":2555310,"end":2555470,"confidence":0.9916992,"speaker":"A"},{"text":"looks","start":2555470,"end":2555670,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":2555670,"end":2555790,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2555790,"end":2555910,"confidence":0.9824219,"speaker":"A"},{"text":"was","start":2555910,"end":2555990,"confidence":0.9975586,"speaker":"A"},{"text":"a","start":2555990,"end":2556110,"confidence":0.80810547,"speaker":"A"},{"text":"RSS","start":2556110,"end":2556630,"confidence":0.72924805,"speaker":"A"},{"text":"update.","start":2556630,"end":2557190,"confidence":0.9975586,"speaker":"A"},{"text":"We","start":2558910,"end":2559030,"confidence":0.9663086,"speaker":"A"},{"text":"get","start":2559030,"end":2559150,"confidence":0.5415039,"speaker":"A"},{"text":"the","start":2559150,"end":2559270,"confidence":0.9970703,"speaker":"A"},{"text":"workflow","start":2559270,"end":2559790,"confidence":0.9992676,"speaker":"A"},{"text":"file","start":2559790,"end":2560190,"confidence":0.79589844,"speaker":"A"},{"text":"and.","start":2562510,"end":2562830,"confidence":0.8984375,"speaker":"A"},{"text":"Oh,","start":2562830,"end":2563150,"confidence":0.78930664,"speaker":"A"},{"text":"sorry,","start":2563150,"end":2563430,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2563430,"end":2563590,"confidence":0.99902344,"speaker":"A"},{"text":"should","start":2563590,"end":2563830,"confidence":0.9995117,"speaker":"A"},{"text":"point","start":2563830,"end":2564070,"confidence":1,"speaker":"A"},{"text":"out,","start":2564070,"end":2564270,"confidence":1,"speaker":"A"},{"text":"because","start":2564270,"end":2564470,"confidence":0.96191406,"speaker":"A"},{"text":"you're","start":2564470,"end":2564670,"confidence":0.9991862,"speaker":"A"},{"text":"probably","start":2564670,"end":2564870,"confidence":1,"speaker":"A"},{"text":"wondering","start":2564870,"end":2565270,"confidence":0.99121094,"speaker":"A"},{"text":"where","start":2565270,"end":2565510,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2565510,"end":2565670,"confidence":0.88183594,"speaker":"A"},{"text":"all","start":2565670,"end":2565830,"confidence":0.99121094,"speaker":"A"},{"text":"these.","start":2565830,"end":2566110,"confidence":0.8798828,"speaker":"A"},{"text":"The","start":2566110,"end":2566390,"confidence":0.8417969,"speaker":"A"},{"text":"stuff","start":2566390,"end":2566710,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":2566710,"end":2566950,"confidence":0.9892578,"speaker":"A"},{"text":"these","start":2566950,"end":2567110,"confidence":0.7866211,"speaker":"A"},{"text":"secrets","start":2567110,"end":2567510,"confidence":0.97875977,"speaker":"A"},{"text":"stored?","start":2567510,"end":2567870,"confidence":0.98657227,"speaker":"A"},{"text":"Yes,","start":2567870,"end":2568150,"confidence":0.99975586,"speaker":"A"},{"text":"they","start":2568150,"end":2568310,"confidence":0.99902344,"speaker":"A"},{"text":"are","start":2568310,"end":2568510,"confidence":0.99902344,"speaker":"A"},{"text":"stored","start":2568510,"end":2568990,"confidence":0.99731445,"speaker":"A"},{"text":"in","start":2569790,"end":2570150,"confidence":0.9765625,"speaker":"A"},{"text":"Actions","start":2570150,"end":2570830,"confidence":0.9909668,"speaker":"A"},{"text":"secrets","start":2570990,"end":2571790,"confidence":0.998291,"speaker":"A"},{"text":"right","start":2572430,"end":2572750,"confidence":0.99853516,"speaker":"A"},{"text":"here.","start":2572750,"end":2573070,"confidence":0.9995117,"speaker":"A"}]},{"text":"So we have our private key ID API key from Virtual Buddy. So that's all stored there. Here is Celestra. It's for updating RSS feeds. So it just basically goes through.","start":2573310,"end":2588490,"confidence":0.94384766,"words":[{"text":"So","start":2573310,"end":2573589,"confidence":0.94384766,"speaker":"A"},{"text":"we","start":2573589,"end":2573750,"confidence":1,"speaker":"A"},{"text":"have","start":2573750,"end":2573910,"confidence":1,"speaker":"A"},{"text":"our","start":2573910,"end":2574070,"confidence":0.8671875,"speaker":"A"},{"text":"private","start":2574070,"end":2574310,"confidence":0.9995117,"speaker":"A"},{"text":"key","start":2574310,"end":2574670,"confidence":0.9980469,"speaker":"A"},{"text":"ID","start":2575310,"end":2575710,"confidence":0.8774414,"speaker":"A"},{"text":"API","start":2576510,"end":2577070,"confidence":0.98535156,"speaker":"A"},{"text":"key","start":2577070,"end":2577390,"confidence":0.9970703,"speaker":"A"},{"text":"from","start":2577790,"end":2578190,"confidence":0.9995117,"speaker":"A"},{"text":"Virtual","start":2578190,"end":2578670,"confidence":0.99975586,"speaker":"A"},{"text":"Buddy.","start":2578670,"end":2579150,"confidence":0.97786456,"speaker":"A"},{"text":"So","start":2579550,"end":2579950,"confidence":0.9667969,"speaker":"A"},{"text":"that's","start":2580030,"end":2580430,"confidence":0.99625653,"speaker":"A"},{"text":"all","start":2580430,"end":2580550,"confidence":0.98779297,"speaker":"A"},{"text":"stored","start":2580550,"end":2580950,"confidence":0.9921875,"speaker":"A"},{"text":"there.","start":2580950,"end":2581230,"confidence":0.99658203,"speaker":"A"},{"text":"Here","start":2581870,"end":2582270,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":2582350,"end":2582750,"confidence":0.9975586,"speaker":"A"},{"text":"Celestra.","start":2583150,"end":2583950,"confidence":0.8902995,"speaker":"A"},{"text":"It's","start":2584270,"end":2584710,"confidence":0.99886066,"speaker":"A"},{"text":"for","start":2584710,"end":2584910,"confidence":0.99902344,"speaker":"A"},{"text":"updating","start":2584910,"end":2585350,"confidence":0.9995117,"speaker":"A"},{"text":"RSS","start":2585350,"end":2585830,"confidence":0.9616699,"speaker":"A"},{"text":"feeds.","start":2585830,"end":2586350,"confidence":0.9967448,"speaker":"A"},{"text":"So","start":2587050,"end":2587130,"confidence":0.97216797,"speaker":"A"},{"text":"it","start":2587130,"end":2587210,"confidence":0.9663086,"speaker":"A"},{"text":"just","start":2587210,"end":2587370,"confidence":0.9951172,"speaker":"A"},{"text":"basically","start":2587370,"end":2587810,"confidence":0.99975586,"speaker":"A"},{"text":"goes","start":2587810,"end":2588170,"confidence":0.9995117,"speaker":"A"},{"text":"through.","start":2588170,"end":2588490,"confidence":0.9995117,"speaker":"A"}]},{"text":"You can look at the Swift code it goes through, pulls RSS feeds and updates them into a CloudKit record or what do you call it? Yeah, record type. And I of course try to do it in such a way not to hammer people, but same idea, yeah, it goes ahead and it runs the binary it updates and then I also have like actual parameters that I take to to filter out, like which RSS feeds are high priority and which ones aren't based on the audience and etc. So yeah, so that's deployment. That's how you can get that working.","start":2588570,"end":2628410,"confidence":0.9995117,"words":[{"text":"You","start":2588570,"end":2588810,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2588810,"end":2588930,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":2588930,"end":2589090,"confidence":1,"speaker":"A"},{"text":"at","start":2589090,"end":2589210,"confidence":1,"speaker":"A"},{"text":"the","start":2589210,"end":2589290,"confidence":0.9951172,"speaker":"A"},{"text":"Swift","start":2589290,"end":2589610,"confidence":0.99902344,"speaker":"A"},{"text":"code","start":2589610,"end":2589930,"confidence":0.976888,"speaker":"A"},{"text":"it","start":2589930,"end":2590130,"confidence":0.9995117,"speaker":"A"},{"text":"goes","start":2590130,"end":2590370,"confidence":0.9995117,"speaker":"A"},{"text":"through,","start":2590370,"end":2590610,"confidence":0.9995117,"speaker":"A"},{"text":"pulls","start":2590610,"end":2590970,"confidence":0.97249347,"speaker":"A"},{"text":"RSS","start":2590970,"end":2591370,"confidence":0.98217773,"speaker":"A"},{"text":"feeds","start":2591370,"end":2591890,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":2591890,"end":2592090,"confidence":0.9975586,"speaker":"A"},{"text":"updates","start":2592090,"end":2592650,"confidence":0.9995117,"speaker":"A"},{"text":"them","start":2593050,"end":2593370,"confidence":0.98876953,"speaker":"A"},{"text":"into","start":2593370,"end":2593650,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2593650,"end":2593850,"confidence":0.9970703,"speaker":"A"},{"text":"CloudKit","start":2593850,"end":2594490,"confidence":0.9980469,"speaker":"A"},{"text":"record","start":2595530,"end":2595930,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":2596410,"end":2596810,"confidence":0.9975586,"speaker":"A"},{"text":"what","start":2596890,"end":2597130,"confidence":0.9321289,"speaker":"A"},{"text":"do","start":2597130,"end":2597210,"confidence":0.8364258,"speaker":"A"},{"text":"you","start":2597210,"end":2597290,"confidence":0.9980469,"speaker":"A"},{"text":"call","start":2597290,"end":2597370,"confidence":1,"speaker":"A"},{"text":"it?","start":2597370,"end":2597490,"confidence":0.9951172,"speaker":"A"},{"text":"Yeah,","start":2597490,"end":2597730,"confidence":0.9558919,"speaker":"A"},{"text":"record","start":2597730,"end":2598010,"confidence":0.99853516,"speaker":"A"},{"text":"type.","start":2598010,"end":2598490,"confidence":0.9250488,"speaker":"A"},{"text":"And","start":2599850,"end":2600130,"confidence":0.9638672,"speaker":"A"},{"text":"I","start":2600130,"end":2600290,"confidence":0.9946289,"speaker":"A"},{"text":"of","start":2600290,"end":2600410,"confidence":0.64501953,"speaker":"A"},{"text":"course","start":2600410,"end":2600570,"confidence":0.9995117,"speaker":"A"},{"text":"try","start":2600570,"end":2600770,"confidence":0.9506836,"speaker":"A"},{"text":"to","start":2600770,"end":2600890,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":2600890,"end":2600970,"confidence":1,"speaker":"A"},{"text":"it","start":2600970,"end":2601050,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":2601050,"end":2601130,"confidence":0.98876953,"speaker":"A"},{"text":"such","start":2601130,"end":2601250,"confidence":1,"speaker":"A"},{"text":"a","start":2601250,"end":2601370,"confidence":0.96777344,"speaker":"A"},{"text":"way","start":2601370,"end":2601530,"confidence":1,"speaker":"A"},{"text":"not","start":2601530,"end":2601730,"confidence":0.99365234,"speaker":"A"},{"text":"to","start":2601730,"end":2601890,"confidence":0.9980469,"speaker":"A"},{"text":"hammer","start":2601890,"end":2602210,"confidence":0.9998372,"speaker":"A"},{"text":"people,","start":2602210,"end":2602490,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":2602970,"end":2603370,"confidence":0.9902344,"speaker":"A"},{"text":"same","start":2603370,"end":2603690,"confidence":0.9941406,"speaker":"A"},{"text":"idea,","start":2603690,"end":2604170,"confidence":0.9914551,"speaker":"A"},{"text":"yeah,","start":2607050,"end":2607410,"confidence":0.96761066,"speaker":"A"},{"text":"it","start":2607410,"end":2607570,"confidence":0.99902344,"speaker":"A"},{"text":"goes","start":2607570,"end":2607770,"confidence":1,"speaker":"A"},{"text":"ahead","start":2607770,"end":2608010,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2608010,"end":2608330,"confidence":0.9921875,"speaker":"A"},{"text":"it","start":2608330,"end":2608570,"confidence":0.98828125,"speaker":"A"},{"text":"runs","start":2608570,"end":2609130,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2610330,"end":2610610,"confidence":0.9995117,"speaker":"A"},{"text":"binary","start":2610610,"end":2611210,"confidence":0.9991862,"speaker":"A"},{"text":"it","start":2611210,"end":2611530,"confidence":0.9711914,"speaker":"A"},{"text":"updates","start":2611530,"end":2612010,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":2612170,"end":2612410,"confidence":0.98828125,"speaker":"A"},{"text":"then","start":2612410,"end":2612570,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2612570,"end":2612770,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":2612770,"end":2612970,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2612970,"end":2613290,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":2613290,"end":2613650,"confidence":0.9321289,"speaker":"A"},{"text":"actual","start":2613650,"end":2614170,"confidence":0.99853516,"speaker":"A"},{"text":"parameters","start":2615370,"end":2615890,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":2615890,"end":2616010,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2616010,"end":2616130,"confidence":0.9995117,"speaker":"A"},{"text":"take","start":2616130,"end":2616330,"confidence":1,"speaker":"A"},{"text":"to","start":2616330,"end":2616570,"confidence":0.97314453,"speaker":"A"},{"text":"to","start":2616570,"end":2616810,"confidence":0.9995117,"speaker":"A"},{"text":"filter","start":2616810,"end":2617170,"confidence":0.9663086,"speaker":"A"},{"text":"out,","start":2617170,"end":2617410,"confidence":1,"speaker":"A"},{"text":"like","start":2617410,"end":2617610,"confidence":0.99658203,"speaker":"A"},{"text":"which","start":2617610,"end":2617890,"confidence":0.99902344,"speaker":"A"},{"text":"RSS","start":2617890,"end":2618410,"confidence":0.99853516,"speaker":"A"},{"text":"feeds","start":2618410,"end":2618970,"confidence":0.9991862,"speaker":"A"},{"text":"are","start":2619290,"end":2619610,"confidence":0.96240234,"speaker":"A"},{"text":"high","start":2619610,"end":2619810,"confidence":1,"speaker":"A"},{"text":"priority","start":2619810,"end":2620170,"confidence":1,"speaker":"A"},{"text":"and","start":2620170,"end":2620330,"confidence":0.92626953,"speaker":"A"},{"text":"which","start":2620330,"end":2620450,"confidence":1,"speaker":"A"},{"text":"ones","start":2620450,"end":2620690,"confidence":0.9995117,"speaker":"A"},{"text":"aren't","start":2620690,"end":2621010,"confidence":0.99768066,"speaker":"A"},{"text":"based","start":2621010,"end":2621170,"confidence":1,"speaker":"A"},{"text":"on","start":2621170,"end":2621330,"confidence":1,"speaker":"A"},{"text":"the","start":2621330,"end":2621490,"confidence":0.99365234,"speaker":"A"},{"text":"audience","start":2621490,"end":2621770,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2621770,"end":2621970,"confidence":0.9975586,"speaker":"A"},{"text":"etc.","start":2621970,"end":2622650,"confidence":0.90723,"speaker":"A"},{"text":"So","start":2622650,"end":2623050,"confidence":0.9946289,"speaker":"A"},{"text":"yeah,","start":2623850,"end":2624330,"confidence":0.95377606,"speaker":"A"},{"text":"so","start":2624890,"end":2625170,"confidence":0.99853516,"speaker":"A"},{"text":"that's","start":2625170,"end":2625450,"confidence":0.9946289,"speaker":"A"},{"text":"deployment.","start":2625450,"end":2626170,"confidence":0.9991862,"speaker":"A"},{"text":"That's","start":2627050,"end":2627450,"confidence":0.9998372,"speaker":"A"},{"text":"how","start":2627450,"end":2627530,"confidence":1,"speaker":"A"},{"text":"you","start":2627530,"end":2627650,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2627650,"end":2627770,"confidence":1,"speaker":"A"},{"text":"get","start":2627770,"end":2627890,"confidence":1,"speaker":"A"},{"text":"that","start":2627890,"end":2628090,"confidence":1,"speaker":"A"},{"text":"working.","start":2628090,"end":2628410,"confidence":0.9995117,"speaker":"A"}]},{"text":"There's weird stuff with cloud with GitHub that I've noticed. If you haven't updated it in a while, it doesn't run these cron jobs. So I need to figure out a how to get around it or find another service to do it. This is all free because it's public and it is running on Ubuntu. So that's really great.","start":2628810,"end":2649870,"confidence":0.9996745,"words":[{"text":"There's","start":2628810,"end":2629250,"confidence":0.9996745,"speaker":"A"},{"text":"weird","start":2629250,"end":2629490,"confidence":1,"speaker":"A"},{"text":"stuff","start":2629490,"end":2629690,"confidence":1,"speaker":"A"},{"text":"with","start":2629690,"end":2629850,"confidence":0.99609375,"speaker":"A"},{"text":"cloud","start":2629850,"end":2630290,"confidence":0.8815918,"speaker":"A"},{"text":"with","start":2630290,"end":2630650,"confidence":0.9873047,"speaker":"A"},{"text":"GitHub","start":2630810,"end":2631530,"confidence":0.99853516,"speaker":"A"},{"text":"that","start":2632730,"end":2633130,"confidence":0.9975586,"speaker":"A"},{"text":"I've","start":2633690,"end":2634010,"confidence":1,"speaker":"A"},{"text":"noticed.","start":2634010,"end":2634330,"confidence":0.99869794,"speaker":"A"},{"text":"If","start":2634330,"end":2634530,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":2634530,"end":2634730,"confidence":0.9995117,"speaker":"A"},{"text":"haven't","start":2634730,"end":2635010,"confidence":0.9984131,"speaker":"A"},{"text":"updated","start":2635010,"end":2635370,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2635370,"end":2635610,"confidence":0.96240234,"speaker":"A"},{"text":"in","start":2635610,"end":2635810,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":2635810,"end":2635970,"confidence":0.99560547,"speaker":"A"},{"text":"while,","start":2635970,"end":2636250,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2636250,"end":2636530,"confidence":1,"speaker":"A"},{"text":"doesn't","start":2636530,"end":2636770,"confidence":0.9998372,"speaker":"A"},{"text":"run","start":2636770,"end":2636970,"confidence":0.99853516,"speaker":"A"},{"text":"these","start":2636970,"end":2637210,"confidence":0.96777344,"speaker":"A"},{"text":"cron","start":2637210,"end":2637490,"confidence":0.90527344,"speaker":"A"},{"text":"jobs.","start":2637490,"end":2637770,"confidence":0.99072266,"speaker":"A"},{"text":"So","start":2637770,"end":2637850,"confidence":0.9951172,"speaker":"A"},{"text":"I","start":2637850,"end":2637930,"confidence":1,"speaker":"A"},{"text":"need","start":2637930,"end":2638050,"confidence":1,"speaker":"A"},{"text":"to","start":2638050,"end":2638170,"confidence":0.99902344,"speaker":"A"},{"text":"figure","start":2638170,"end":2638330,"confidence":0.99975586,"speaker":"A"},{"text":"out","start":2638330,"end":2638490,"confidence":0.98828125,"speaker":"A"},{"text":"a","start":2638490,"end":2638690,"confidence":0.89941406,"speaker":"A"},{"text":"how","start":2638690,"end":2638850,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":2638850,"end":2638970,"confidence":0.9995117,"speaker":"A"},{"text":"get","start":2638970,"end":2639050,"confidence":0.9995117,"speaker":"A"},{"text":"around","start":2639050,"end":2639210,"confidence":0.99853516,"speaker":"A"},{"text":"it","start":2639210,"end":2639410,"confidence":0.9238281,"speaker":"A"},{"text":"or","start":2639410,"end":2639570,"confidence":0.9995117,"speaker":"A"},{"text":"find","start":2639570,"end":2639730,"confidence":0.9995117,"speaker":"A"},{"text":"another","start":2639730,"end":2640010,"confidence":0.9477539,"speaker":"A"},{"text":"service","start":2640090,"end":2640450,"confidence":0.9819336,"speaker":"A"},{"text":"to","start":2640450,"end":2640650,"confidence":0.9970703,"speaker":"A"},{"text":"do","start":2640650,"end":2640730,"confidence":0.99902344,"speaker":"A"},{"text":"it.","start":2640730,"end":2640970,"confidence":0.9975586,"speaker":"A"},{"text":"This","start":2642830,"end":2642950,"confidence":0.9897461,"speaker":"A"},{"text":"is","start":2642950,"end":2643110,"confidence":0.9975586,"speaker":"A"},{"text":"all","start":2643110,"end":2643270,"confidence":0.9995117,"speaker":"A"},{"text":"free","start":2643270,"end":2643550,"confidence":1,"speaker":"A"},{"text":"because","start":2643630,"end":2644030,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":2644110,"end":2644590,"confidence":0.99934894,"speaker":"A"},{"text":"public","start":2644590,"end":2644870,"confidence":1,"speaker":"A"},{"text":"and","start":2644870,"end":2645230,"confidence":0.7548828,"speaker":"A"},{"text":"it","start":2646990,"end":2647310,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2647310,"end":2647550,"confidence":0.9995117,"speaker":"A"},{"text":"running","start":2647550,"end":2647870,"confidence":0.9987793,"speaker":"A"},{"text":"on","start":2647870,"end":2647990,"confidence":0.7963867,"speaker":"A"},{"text":"Ubuntu.","start":2647990,"end":2648590,"confidence":0.8631836,"speaker":"A"},{"text":"So","start":2648670,"end":2648910,"confidence":0.9980469,"speaker":"A"},{"text":"that's","start":2648910,"end":2649310,"confidence":0.99934894,"speaker":"A"},{"text":"really","start":2649310,"end":2649550,"confidence":1,"speaker":"A"},{"text":"great.","start":2649550,"end":2649870,"confidence":0.99902344,"speaker":"A"}]},{"text":"And the storage on CloudKit is dirt cheap, which is even more awesome.","start":2652350,"end":2656830,"confidence":0.9838867,"words":[{"text":"And","start":2652350,"end":2652750,"confidence":0.9838867,"speaker":"A"},{"text":"the","start":2652830,"end":2653110,"confidence":0.9995117,"speaker":"A"},{"text":"storage","start":2653110,"end":2653430,"confidence":1,"speaker":"A"},{"text":"on","start":2653430,"end":2653590,"confidence":0.9951172,"speaker":"A"},{"text":"CloudKit","start":2653590,"end":2654150,"confidence":0.94189453,"speaker":"A"},{"text":"is","start":2654150,"end":2654310,"confidence":0.99902344,"speaker":"A"},{"text":"dirt","start":2654310,"end":2654590,"confidence":0.8517253,"speaker":"A"},{"text":"cheap,","start":2654590,"end":2654990,"confidence":0.8378906,"speaker":"A"},{"text":"which","start":2655390,"end":2655670,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2655670,"end":2655830,"confidence":1,"speaker":"A"},{"text":"even","start":2655830,"end":2656070,"confidence":1,"speaker":"A"},{"text":"more","start":2656070,"end":2656310,"confidence":1,"speaker":"A"},{"text":"awesome.","start":2656310,"end":2656830,"confidence":0.99886066,"speaker":"A"}]},{"text":"Sorry, let's see what else. I just want to make sure I covered all my slides. The last thing I'm going to talk about is just what are my plans? Excuse me. So I don't know if you check.","start":2660030,"end":2672790,"confidence":0.99593097,"words":[{"text":"Sorry,","start":2660030,"end":2660590,"confidence":0.99593097,"speaker":"A"},{"text":"let's","start":2660990,"end":2661350,"confidence":0.89501953,"speaker":"A"},{"text":"see","start":2661350,"end":2661550,"confidence":0.9848633,"speaker":"A"},{"text":"what","start":2661550,"end":2661750,"confidence":0.99609375,"speaker":"A"},{"text":"else.","start":2661750,"end":2662110,"confidence":0.99975586,"speaker":"A"},{"text":"I","start":2663630,"end":2663870,"confidence":0.9682617,"speaker":"A"},{"text":"just","start":2663870,"end":2663990,"confidence":0.9824219,"speaker":"A"},{"text":"want","start":2663990,"end":2664110,"confidence":0.75878906,"speaker":"A"},{"text":"to","start":2664110,"end":2664230,"confidence":0.7807617,"speaker":"A"},{"text":"make","start":2664230,"end":2664350,"confidence":0.9995117,"speaker":"A"},{"text":"sure","start":2664350,"end":2664430,"confidence":1,"speaker":"A"},{"text":"I","start":2664430,"end":2664550,"confidence":0.98779297,"speaker":"A"},{"text":"covered","start":2664550,"end":2664870,"confidence":0.99975586,"speaker":"A"},{"text":"all","start":2664870,"end":2665070,"confidence":0.99902344,"speaker":"A"},{"text":"my","start":2665070,"end":2665390,"confidence":0.9970703,"speaker":"A"},{"text":"slides.","start":2665630,"end":2666150,"confidence":0.99975586,"speaker":"A"},{"text":"The","start":2666150,"end":2666390,"confidence":0.9995117,"speaker":"A"},{"text":"last","start":2666390,"end":2666590,"confidence":1,"speaker":"A"},{"text":"thing","start":2666590,"end":2666790,"confidence":1,"speaker":"A"},{"text":"I'm","start":2666790,"end":2666990,"confidence":0.9980469,"speaker":"A"},{"text":"going","start":2666990,"end":2667070,"confidence":0.96777344,"speaker":"A"},{"text":"to","start":2667070,"end":2667150,"confidence":0.9995117,"speaker":"A"},{"text":"talk","start":2667150,"end":2667270,"confidence":1,"speaker":"A"},{"text":"about","start":2667270,"end":2667470,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2667470,"end":2667670,"confidence":0.9941406,"speaker":"A"},{"text":"just","start":2667670,"end":2667830,"confidence":0.9941406,"speaker":"A"},{"text":"what","start":2667830,"end":2667990,"confidence":0.99853516,"speaker":"A"},{"text":"are","start":2667990,"end":2668150,"confidence":0.99902344,"speaker":"A"},{"text":"my","start":2668150,"end":2668310,"confidence":1,"speaker":"A"},{"text":"plans?","start":2668310,"end":2668670,"confidence":0.92578125,"speaker":"A"},{"text":"Excuse","start":2670390,"end":2670750,"confidence":0.9793294,"speaker":"A"},{"text":"me.","start":2670750,"end":2671030,"confidence":1,"speaker":"A"},{"text":"So","start":2671510,"end":2671790,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":2671790,"end":2671910,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":2671910,"end":2672070,"confidence":0.99934894,"speaker":"A"},{"text":"know","start":2672070,"end":2672150,"confidence":1,"speaker":"A"},{"text":"if","start":2672150,"end":2672230,"confidence":1,"speaker":"A"},{"text":"you","start":2672230,"end":2672390,"confidence":0.9995117,"speaker":"A"},{"text":"check.","start":2672390,"end":2672790,"confidence":0.7727051,"speaker":"A"}]},{"text":"Follow me. But I just released.","start":2672790,"end":2674550,"confidence":0.9663086,"words":[{"text":"Follow","start":2672790,"end":2673150,"confidence":0.9663086,"speaker":"A"},{"text":"me.","start":2673150,"end":2673390,"confidence":1,"speaker":"A"},{"text":"But","start":2673390,"end":2673550,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":2673550,"end":2673710,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":2673710,"end":2673910,"confidence":0.99902344,"speaker":"A"},{"text":"released.","start":2673910,"end":2674550,"confidence":0.99975586,"speaker":"A"}]},{"text":"I just released Alpha 5 that has lookup zones, fetch, record changes and upload assets. Upload the assets is pretty awesome. When I saw that work because I was like, cool, I can actually upload a binary to CloudKit, which is awesome. We got query filters to work for in and not in, so you could do that I have plans to continue working on this because I think there's a big future for something like this for a lot of people.","start":2681910,"end":2706990,"confidence":0.98876953,"words":[{"text":"I","start":2681910,"end":2682190,"confidence":0.98876953,"speaker":"A"},{"text":"just","start":2682190,"end":2682350,"confidence":1,"speaker":"A"},{"text":"released","start":2682350,"end":2682710,"confidence":0.99975586,"speaker":"A"},{"text":"Alpha","start":2682710,"end":2683150,"confidence":0.85091144,"speaker":"A"},{"text":"5","start":2683150,"end":2683430,"confidence":0.99414,"speaker":"A"},{"text":"that","start":2684310,"end":2684630,"confidence":1,"speaker":"A"},{"text":"has","start":2684630,"end":2684909,"confidence":0.9995117,"speaker":"A"},{"text":"lookup","start":2684909,"end":2685390,"confidence":0.89086914,"speaker":"A"},{"text":"zones,","start":2685390,"end":2685750,"confidence":0.9760742,"speaker":"A"},{"text":"fetch,","start":2685750,"end":2686150,"confidence":0.9900716,"speaker":"A"},{"text":"record","start":2686150,"end":2686430,"confidence":0.9995117,"speaker":"A"},{"text":"changes","start":2686430,"end":2686870,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2686870,"end":2687030,"confidence":0.6220703,"speaker":"A"},{"text":"upload","start":2687030,"end":2687430,"confidence":0.71809894,"speaker":"A"},{"text":"assets.","start":2687430,"end":2687990,"confidence":1,"speaker":"A"},{"text":"Upload","start":2688310,"end":2688750,"confidence":0.9840495,"speaker":"A"},{"text":"the","start":2688750,"end":2688910,"confidence":0.7114258,"speaker":"A"},{"text":"assets","start":2688910,"end":2689270,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2689270,"end":2689470,"confidence":0.9814453,"speaker":"A"},{"text":"pretty","start":2689470,"end":2689710,"confidence":1,"speaker":"A"},{"text":"awesome.","start":2689710,"end":2690150,"confidence":1,"speaker":"A"},{"text":"When","start":2690230,"end":2690510,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2690510,"end":2690670,"confidence":1,"speaker":"A"},{"text":"saw","start":2690670,"end":2690830,"confidence":1,"speaker":"A"},{"text":"that","start":2690830,"end":2691030,"confidence":0.9995117,"speaker":"A"},{"text":"work","start":2691030,"end":2691310,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":2691310,"end":2691590,"confidence":1,"speaker":"A"},{"text":"I","start":2691590,"end":2691750,"confidence":0.9536133,"speaker":"A"},{"text":"was","start":2691750,"end":2691870,"confidence":0.9975586,"speaker":"A"},{"text":"like,","start":2691870,"end":2691990,"confidence":0.9980469,"speaker":"A"},{"text":"cool,","start":2691990,"end":2692190,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":2692190,"end":2692310,"confidence":0.9951172,"speaker":"A"},{"text":"can","start":2692310,"end":2692470,"confidence":0.9970703,"speaker":"A"},{"text":"actually","start":2692470,"end":2692670,"confidence":0.9995117,"speaker":"A"},{"text":"upload","start":2692670,"end":2693030,"confidence":1,"speaker":"A"},{"text":"a","start":2693030,"end":2693150,"confidence":0.9951172,"speaker":"A"},{"text":"binary","start":2693150,"end":2693750,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":2694630,"end":2694910,"confidence":0.96728516,"speaker":"A"},{"text":"CloudKit,","start":2694910,"end":2695510,"confidence":0.98046875,"speaker":"A"},{"text":"which","start":2695510,"end":2695710,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2695710,"end":2695830,"confidence":0.9995117,"speaker":"A"},{"text":"awesome.","start":2695830,"end":2696230,"confidence":0.9998372,"speaker":"A"},{"text":"We","start":2697310,"end":2697430,"confidence":0.99121094,"speaker":"A"},{"text":"got","start":2697430,"end":2697630,"confidence":0.9946289,"speaker":"A"},{"text":"query","start":2697630,"end":2697990,"confidence":0.9836426,"speaker":"A"},{"text":"filters","start":2697990,"end":2698470,"confidence":0.9889323,"speaker":"A"},{"text":"to","start":2698470,"end":2698630,"confidence":0.99853516,"speaker":"A"},{"text":"work","start":2698630,"end":2698790,"confidence":1,"speaker":"A"},{"text":"for","start":2698790,"end":2698950,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":2698950,"end":2699150,"confidence":0.88183594,"speaker":"A"},{"text":"and","start":2699150,"end":2699310,"confidence":0.9741211,"speaker":"A"},{"text":"not","start":2699310,"end":2699510,"confidence":0.98339844,"speaker":"A"},{"text":"in,","start":2699510,"end":2699870,"confidence":0.8652344,"speaker":"A"},{"text":"so","start":2699870,"end":2700110,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":2700110,"end":2700190,"confidence":0.99853516,"speaker":"A"},{"text":"could","start":2700190,"end":2700350,"confidence":0.95410156,"speaker":"A"},{"text":"do","start":2700350,"end":2700550,"confidence":1,"speaker":"A"},{"text":"that","start":2700550,"end":2700830,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2701470,"end":2701790,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2701790,"end":2702110,"confidence":0.9995117,"speaker":"A"},{"text":"plans","start":2702110,"end":2702630,"confidence":0.95043945,"speaker":"A"},{"text":"to","start":2702630,"end":2702750,"confidence":0.95166016,"speaker":"A"},{"text":"continue","start":2702750,"end":2702950,"confidence":0.9980469,"speaker":"A"},{"text":"working","start":2702950,"end":2703230,"confidence":0.9238281,"speaker":"A"},{"text":"on","start":2703230,"end":2703430,"confidence":0.99853516,"speaker":"A"},{"text":"this","start":2703430,"end":2703630,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":2703630,"end":2703830,"confidence":0.9555664,"speaker":"A"},{"text":"I","start":2703830,"end":2703990,"confidence":0.9995117,"speaker":"A"},{"text":"think","start":2703990,"end":2704230,"confidence":0.99902344,"speaker":"A"},{"text":"there's","start":2704230,"end":2704710,"confidence":0.9991862,"speaker":"A"},{"text":"a","start":2704710,"end":2704830,"confidence":0.9995117,"speaker":"A"},{"text":"big","start":2704830,"end":2704990,"confidence":0.99902344,"speaker":"A"},{"text":"future","start":2704990,"end":2705270,"confidence":0.9970703,"speaker":"A"},{"text":"for","start":2705270,"end":2705510,"confidence":0.9995117,"speaker":"A"},{"text":"something","start":2705510,"end":2705750,"confidence":0.99560547,"speaker":"A"},{"text":"like","start":2705750,"end":2705990,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2705990,"end":2706190,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":2706190,"end":2706390,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2706390,"end":2706510,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":2706510,"end":2706590,"confidence":1,"speaker":"A"},{"text":"of","start":2706590,"end":2706710,"confidence":0.9995117,"speaker":"A"},{"text":"people.","start":2706710,"end":2706990,"confidence":0.9995117,"speaker":"A"}]},{"text":"Yes, you can technically use this in Android or Windows because the Swift thing does compile in Android and Windows. You can see I already added support for that. This is the support I recently had. And then we're. I'm just kind of like going through each of these because as great as AI is, it's not perfect.","start":2709150,"end":2727000,"confidence":0.9716797,"words":[{"text":"Yes,","start":2709150,"end":2709590,"confidence":0.9716797,"speaker":"A"},{"text":"you","start":2709590,"end":2709830,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2709830,"end":2709990,"confidence":0.93603516,"speaker":"A"},{"text":"technically","start":2709990,"end":2710350,"confidence":0.9992676,"speaker":"A"},{"text":"use","start":2710350,"end":2710590,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2710590,"end":2710790,"confidence":0.98095703,"speaker":"A"},{"text":"in","start":2710790,"end":2710950,"confidence":0.9633789,"speaker":"A"},{"text":"Android","start":2710950,"end":2711470,"confidence":0.99934894,"speaker":"A"},{"text":"or","start":2711470,"end":2711710,"confidence":0.9995117,"speaker":"A"},{"text":"Windows","start":2711710,"end":2712270,"confidence":0.9972331,"speaker":"A"},{"text":"because","start":2712670,"end":2713070,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2713230,"end":2713510,"confidence":0.9970703,"speaker":"A"},{"text":"Swift","start":2713510,"end":2713950,"confidence":0.998291,"speaker":"A"},{"text":"thing","start":2714270,"end":2714590,"confidence":0.99902344,"speaker":"A"},{"text":"does","start":2714590,"end":2714830,"confidence":0.9995117,"speaker":"A"},{"text":"compile","start":2714830,"end":2715190,"confidence":0.99487305,"speaker":"A"},{"text":"in","start":2715190,"end":2715350,"confidence":0.78271484,"speaker":"A"},{"text":"Android","start":2715350,"end":2715750,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2715750,"end":2715910,"confidence":0.72753906,"speaker":"A"},{"text":"Windows.","start":2715910,"end":2716230,"confidence":0.99934894,"speaker":"A"},{"text":"You","start":2716230,"end":2716350,"confidence":0.9970703,"speaker":"A"},{"text":"can","start":2716350,"end":2716430,"confidence":0.88623047,"speaker":"A"},{"text":"see","start":2716430,"end":2716550,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2716550,"end":2716670,"confidence":0.63378906,"speaker":"A"},{"text":"already","start":2716670,"end":2716830,"confidence":0.99560547,"speaker":"A"},{"text":"added","start":2716830,"end":2717110,"confidence":0.9819336,"speaker":"A"},{"text":"support","start":2717110,"end":2717430,"confidence":1,"speaker":"A"},{"text":"for","start":2717430,"end":2717670,"confidence":1,"speaker":"A"},{"text":"that.","start":2717670,"end":2717950,"confidence":0.9995117,"speaker":"A"},{"text":"This","start":2718430,"end":2718710,"confidence":0.99609375,"speaker":"A"},{"text":"is","start":2718710,"end":2718870,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":2718870,"end":2719030,"confidence":0.88720703,"speaker":"A"},{"text":"support","start":2719030,"end":2719270,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":2719270,"end":2719510,"confidence":0.99658203,"speaker":"A"},{"text":"recently","start":2719510,"end":2719790,"confidence":1,"speaker":"A"},{"text":"had.","start":2719870,"end":2720270,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":2720750,"end":2721030,"confidence":0.9814453,"speaker":"A"},{"text":"then","start":2721030,"end":2721310,"confidence":0.99121094,"speaker":"A"},{"text":"we're.","start":2722120,"end":2722360,"confidence":0.77229816,"speaker":"A"},{"text":"I'm","start":2722360,"end":2722600,"confidence":0.9868164,"speaker":"A"},{"text":"just","start":2722600,"end":2722720,"confidence":0.9995117,"speaker":"A"},{"text":"kind","start":2722720,"end":2722840,"confidence":0.9946289,"speaker":"A"},{"text":"of","start":2722840,"end":2722960,"confidence":0.9370117,"speaker":"A"},{"text":"like","start":2722960,"end":2723200,"confidence":0.99609375,"speaker":"A"},{"text":"going","start":2723200,"end":2723480,"confidence":0.99902344,"speaker":"A"},{"text":"through","start":2723480,"end":2723720,"confidence":1,"speaker":"A"},{"text":"each","start":2723720,"end":2723920,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":2723920,"end":2724040,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":2724040,"end":2724280,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":2724280,"end":2724680,"confidence":0.7866211,"speaker":"A"},{"text":"as","start":2724680,"end":2725000,"confidence":1,"speaker":"A"},{"text":"great","start":2725000,"end":2725240,"confidence":0.9951172,"speaker":"A"},{"text":"as","start":2725240,"end":2725480,"confidence":0.9946289,"speaker":"A"},{"text":"AI","start":2725480,"end":2725880,"confidence":0.8781738,"speaker":"A"},{"text":"is,","start":2725880,"end":2726160,"confidence":0.9946289,"speaker":"A"},{"text":"it's","start":2726160,"end":2726440,"confidence":0.9995117,"speaker":"A"},{"text":"not","start":2726440,"end":2726600,"confidence":0.9995117,"speaker":"A"},{"text":"perfect.","start":2726600,"end":2727000,"confidence":0.9840495,"speaker":"A"}]},{"text":"So we're just kind of going through these piece by piece with each version and hammering these away and then this is actually done. I don't even know why that's there. But yeah, I think system field integration might already be there and there's a few other things. Eventually I'd like to add support. So there, there's a whole API for CloudKit schema management that I could.","start":2727080,"end":2753200,"confidence":0.99853516,"words":[{"text":"So","start":2727080,"end":2727480,"confidence":0.99853516,"speaker":"A"},{"text":"we're","start":2728040,"end":2728360,"confidence":0.99934894,"speaker":"A"},{"text":"just","start":2728360,"end":2728440,"confidence":1,"speaker":"A"},{"text":"kind","start":2728440,"end":2728560,"confidence":0.99365234,"speaker":"A"},{"text":"of","start":2728560,"end":2728680,"confidence":0.98828125,"speaker":"A"},{"text":"going","start":2728680,"end":2728880,"confidence":0.99365234,"speaker":"A"},{"text":"through","start":2728880,"end":2729120,"confidence":1,"speaker":"A"},{"text":"these","start":2729120,"end":2729400,"confidence":0.98779297,"speaker":"A"},{"text":"piece","start":2729720,"end":2730120,"confidence":0.9848633,"speaker":"A"},{"text":"by","start":2730120,"end":2730360,"confidence":0.99902344,"speaker":"A"},{"text":"piece","start":2730360,"end":2730760,"confidence":0.9983724,"speaker":"A"},{"text":"with","start":2730840,"end":2731120,"confidence":0.9995117,"speaker":"A"},{"text":"each","start":2731120,"end":2731400,"confidence":0.9995117,"speaker":"A"},{"text":"version","start":2731640,"end":2732080,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2732080,"end":2732240,"confidence":0.5917969,"speaker":"A"},{"text":"hammering","start":2732240,"end":2732560,"confidence":0.9977214,"speaker":"A"},{"text":"these","start":2732560,"end":2732760,"confidence":0.99609375,"speaker":"A"},{"text":"away","start":2732760,"end":2733080,"confidence":0.9980469,"speaker":"A"},{"text":"and","start":2735400,"end":2735720,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":2735720,"end":2736040,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":2736680,"end":2736960,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":2736960,"end":2737120,"confidence":0.99365234,"speaker":"A"},{"text":"actually","start":2737120,"end":2737360,"confidence":0.9995117,"speaker":"A"},{"text":"done.","start":2737360,"end":2737640,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":2737640,"end":2737840,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":2737840,"end":2738000,"confidence":0.98844403,"speaker":"A"},{"text":"even","start":2738000,"end":2738159,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":2738159,"end":2738279,"confidence":1,"speaker":"A"},{"text":"why","start":2738279,"end":2738400,"confidence":0.99902344,"speaker":"A"},{"text":"that's","start":2738400,"end":2738680,"confidence":0.9995117,"speaker":"A"},{"text":"there.","start":2738680,"end":2738880,"confidence":0.99853516,"speaker":"A"},{"text":"But","start":2738880,"end":2739240,"confidence":0.99658203,"speaker":"A"},{"text":"yeah,","start":2739640,"end":2740160,"confidence":0.99934894,"speaker":"A"},{"text":"I","start":2740160,"end":2740400,"confidence":0.83203125,"speaker":"A"},{"text":"think","start":2740400,"end":2740680,"confidence":0.92529297,"speaker":"A"},{"text":"system","start":2740680,"end":2741080,"confidence":0.9995117,"speaker":"A"},{"text":"field","start":2741080,"end":2741480,"confidence":0.9916992,"speaker":"A"},{"text":"integration","start":2741640,"end":2742280,"confidence":0.93859863,"speaker":"A"},{"text":"might","start":2742280,"end":2742480,"confidence":0.9980469,"speaker":"A"},{"text":"already","start":2742480,"end":2742720,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2742720,"end":2742960,"confidence":1,"speaker":"A"},{"text":"there","start":2742960,"end":2743240,"confidence":1,"speaker":"A"},{"text":"and","start":2743400,"end":2743680,"confidence":0.9980469,"speaker":"A"},{"text":"there's","start":2743680,"end":2743960,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":2743960,"end":2744040,"confidence":0.9995117,"speaker":"A"},{"text":"few","start":2744040,"end":2744160,"confidence":0.9995117,"speaker":"A"},{"text":"other","start":2744160,"end":2744400,"confidence":1,"speaker":"A"},{"text":"things.","start":2744400,"end":2744760,"confidence":0.9995117,"speaker":"A"},{"text":"Eventually","start":2745960,"end":2746520,"confidence":0.9992676,"speaker":"A"},{"text":"I'd","start":2746520,"end":2746800,"confidence":0.92122394,"speaker":"A"},{"text":"like","start":2746800,"end":2746960,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2746960,"end":2747160,"confidence":0.99902344,"speaker":"A"},{"text":"add","start":2747160,"end":2747480,"confidence":0.9975586,"speaker":"A"},{"text":"support.","start":2747880,"end":2748120,"confidence":0.9902344,"speaker":"A"},{"text":"So","start":2748200,"end":2748480,"confidence":0.99902344,"speaker":"A"},{"text":"there,","start":2748480,"end":2748720,"confidence":0.38134766,"speaker":"A"},{"text":"there's","start":2748720,"end":2749080,"confidence":0.9998372,"speaker":"A"},{"text":"a","start":2749080,"end":2749200,"confidence":0.9995117,"speaker":"A"},{"text":"whole","start":2749200,"end":2749440,"confidence":0.99975586,"speaker":"A"},{"text":"API","start":2749440,"end":2749880,"confidence":0.9975586,"speaker":"A"},{"text":"for","start":2749880,"end":2750120,"confidence":0.9975586,"speaker":"A"},{"text":"CloudKit","start":2750120,"end":2750760,"confidence":0.99609375,"speaker":"A"},{"text":"schema","start":2750760,"end":2751200,"confidence":0.8933919,"speaker":"A"},{"text":"management","start":2751200,"end":2751480,"confidence":0.99121094,"speaker":"A"},{"text":"that","start":2752600,"end":2752880,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2752880,"end":2753000,"confidence":0.9658203,"speaker":"A"},{"text":"could.","start":2753000,"end":2753200,"confidence":0.8144531,"speaker":"A"}]},{"text":"That would be awesome if I could figure out how to do that. If I could figure out how to do key path query filtering, that would be fantastic.","start":2753200,"end":2759400,"confidence":0.99902344,"words":[{"text":"That","start":2753200,"end":2753440,"confidence":0.99902344,"speaker":"A"},{"text":"would","start":2753440,"end":2753560,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2753560,"end":2753680,"confidence":0.9995117,"speaker":"A"},{"text":"awesome","start":2753680,"end":2754080,"confidence":0.9998372,"speaker":"A"},{"text":"if","start":2754080,"end":2754320,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2754320,"end":2754440,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":2754440,"end":2754640,"confidence":0.9863281,"speaker":"A"},{"text":"figure","start":2754640,"end":2754920,"confidence":1,"speaker":"A"},{"text":"out","start":2754920,"end":2755040,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":2755040,"end":2755200,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2755200,"end":2755320,"confidence":1,"speaker":"A"},{"text":"do","start":2755320,"end":2755440,"confidence":0.9995117,"speaker":"A"},{"text":"that.","start":2755440,"end":2755720,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":2755720,"end":2756000,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2756000,"end":2756120,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":2756120,"end":2756240,"confidence":0.84375,"speaker":"A"},{"text":"figure","start":2756240,"end":2756440,"confidence":1,"speaker":"A"},{"text":"out","start":2756440,"end":2756520,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":2756520,"end":2756600,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":2756600,"end":2756680,"confidence":0.9975586,"speaker":"A"},{"text":"do","start":2756680,"end":2756800,"confidence":0.9921875,"speaker":"A"},{"text":"key","start":2756800,"end":2756960,"confidence":0.9682617,"speaker":"A"},{"text":"path","start":2756960,"end":2757280,"confidence":0.953125,"speaker":"A"},{"text":"query","start":2757280,"end":2757600,"confidence":0.9951172,"speaker":"A"},{"text":"filtering,","start":2757600,"end":2758120,"confidence":0.99934894,"speaker":"A"},{"text":"that","start":2758120,"end":2758320,"confidence":0.99902344,"speaker":"A"},{"text":"would","start":2758320,"end":2758480,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2758480,"end":2758640,"confidence":0.9995117,"speaker":"A"},{"text":"fantastic.","start":2758640,"end":2759400,"confidence":0.99890137,"speaker":"A"}]},{"text":"And yeah, but there's a. I mean the basics is there as far as if you want to do anything with a record, it's pretty much there. One thing with Celestra is I'd love to be able to do like test out subscriptions and see how that works. So yeah, that's really the bulk of my presentation today. Now is. Now it's time to ask me a ton of questions and make me feel dumb.","start":2761720,"end":2785480,"confidence":0.9951172,"words":[{"text":"And","start":2761720,"end":2762120,"confidence":0.9951172,"speaker":"A"},{"text":"yeah,","start":2762280,"end":2762760,"confidence":0.9998372,"speaker":"A"},{"text":"but","start":2762760,"end":2762960,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":2762960,"end":2763200,"confidence":0.87320966,"speaker":"A"},{"text":"a.","start":2763200,"end":2763400,"confidence":0.92626953,"speaker":"A"},{"text":"I","start":2763400,"end":2763560,"confidence":0.9980469,"speaker":"A"},{"text":"mean","start":2763560,"end":2763799,"confidence":0.79785156,"speaker":"A"},{"text":"the","start":2763799,"end":2764120,"confidence":0.9995117,"speaker":"A"},{"text":"basics","start":2764120,"end":2764520,"confidence":0.998291,"speaker":"A"},{"text":"is","start":2764520,"end":2764760,"confidence":0.9941406,"speaker":"A"},{"text":"there","start":2764760,"end":2765040,"confidence":0.9995117,"speaker":"A"},{"text":"as","start":2765040,"end":2765280,"confidence":0.9995117,"speaker":"A"},{"text":"far","start":2765280,"end":2765440,"confidence":1,"speaker":"A"},{"text":"as","start":2765440,"end":2765640,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":2765640,"end":2765840,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2765840,"end":2765960,"confidence":0.99902344,"speaker":"A"},{"text":"want","start":2765960,"end":2766080,"confidence":0.77685547,"speaker":"A"},{"text":"to","start":2766080,"end":2766240,"confidence":0.9946289,"speaker":"A"},{"text":"do","start":2766240,"end":2766400,"confidence":1,"speaker":"A"},{"text":"anything","start":2766400,"end":2766760,"confidence":0.99975586,"speaker":"A"},{"text":"with","start":2766760,"end":2766960,"confidence":1,"speaker":"A"},{"text":"a","start":2766960,"end":2767120,"confidence":0.99560547,"speaker":"A"},{"text":"record,","start":2767120,"end":2767400,"confidence":0.99902344,"speaker":"A"},{"text":"it's","start":2768040,"end":2768400,"confidence":0.9983724,"speaker":"A"},{"text":"pretty","start":2768400,"end":2768600,"confidence":0.9998372,"speaker":"A"},{"text":"much","start":2768600,"end":2768760,"confidence":0.99853516,"speaker":"A"},{"text":"there.","start":2768760,"end":2769080,"confidence":0.98583984,"speaker":"A"},{"text":"One","start":2769720,"end":2770000,"confidence":0.9848633,"speaker":"A"},{"text":"thing","start":2770000,"end":2770160,"confidence":0.99853516,"speaker":"A"},{"text":"with","start":2770160,"end":2770320,"confidence":0.9995117,"speaker":"A"},{"text":"Celestra","start":2770320,"end":2770880,"confidence":0.7967122,"speaker":"A"},{"text":"is","start":2770880,"end":2771040,"confidence":0.8798828,"speaker":"A"},{"text":"I'd","start":2771040,"end":2771240,"confidence":0.9977214,"speaker":"A"},{"text":"love","start":2771240,"end":2771400,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2771400,"end":2771560,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2771560,"end":2771720,"confidence":0.99902344,"speaker":"A"},{"text":"able","start":2771720,"end":2771920,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2771920,"end":2772080,"confidence":1,"speaker":"A"},{"text":"do","start":2772080,"end":2772280,"confidence":1,"speaker":"A"},{"text":"like","start":2772280,"end":2772560,"confidence":0.99902344,"speaker":"A"},{"text":"test","start":2772560,"end":2772880,"confidence":0.99853516,"speaker":"A"},{"text":"out","start":2772880,"end":2773160,"confidence":0.9970703,"speaker":"A"},{"text":"subscriptions","start":2773160,"end":2773880,"confidence":0.9428711,"speaker":"A"},{"text":"and","start":2774200,"end":2774320,"confidence":0.94921875,"speaker":"A"},{"text":"see","start":2774320,"end":2774480,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":2774480,"end":2774640,"confidence":1,"speaker":"A"},{"text":"that","start":2774640,"end":2774800,"confidence":1,"speaker":"A"},{"text":"works.","start":2774800,"end":2775240,"confidence":1,"speaker":"A"},{"text":"So","start":2775880,"end":2776280,"confidence":0.99609375,"speaker":"A"},{"text":"yeah,","start":2777320,"end":2777840,"confidence":0.9996745,"speaker":"A"},{"text":"that's","start":2777840,"end":2778200,"confidence":1,"speaker":"A"},{"text":"really","start":2778200,"end":2778360,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2778360,"end":2778560,"confidence":1,"speaker":"A"},{"text":"bulk","start":2778560,"end":2778800,"confidence":0.9817708,"speaker":"A"},{"text":"of","start":2778800,"end":2778960,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":2778960,"end":2779120,"confidence":0.9995117,"speaker":"A"},{"text":"presentation","start":2779120,"end":2779720,"confidence":0.9995117,"speaker":"A"},{"text":"today.","start":2779720,"end":2780040,"confidence":0.99902344,"speaker":"A"},{"text":"Now","start":2781800,"end":2782160,"confidence":0.95751953,"speaker":"A"},{"text":"is.","start":2782160,"end":2782480,"confidence":0.8334961,"speaker":"A"},{"text":"Now","start":2782480,"end":2782720,"confidence":0.99902344,"speaker":"A"},{"text":"it's","start":2782720,"end":2782920,"confidence":0.99869794,"speaker":"A"},{"text":"time","start":2782920,"end":2783040,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2783040,"end":2783160,"confidence":0.9995117,"speaker":"A"},{"text":"ask","start":2783160,"end":2783280,"confidence":0.99902344,"speaker":"A"},{"text":"me","start":2783280,"end":2783440,"confidence":0.99658203,"speaker":"A"},{"text":"a","start":2783440,"end":2783560,"confidence":0.99902344,"speaker":"A"},{"text":"ton","start":2783560,"end":2783720,"confidence":0.9992676,"speaker":"A"},{"text":"of","start":2783720,"end":2783840,"confidence":0.9995117,"speaker":"A"},{"text":"questions","start":2783840,"end":2784200,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2784200,"end":2784480,"confidence":0.9814453,"speaker":"A"},{"text":"make","start":2784480,"end":2784720,"confidence":0.9995117,"speaker":"A"},{"text":"me","start":2784720,"end":2784880,"confidence":0.9995117,"speaker":"A"},{"text":"feel","start":2784880,"end":2785040,"confidence":1,"speaker":"A"},{"text":"dumb.","start":2785040,"end":2785480,"confidence":0.98706055,"speaker":"A"}]},{"text":"Go for it. No, there's a lot there to. To absorb. But I, I like the concept and I know you've been working on this for a while and I always thought it was a pretty cool, pretty cool idea and implementation of this. Questions?","start":2785880,"end":2803470,"confidence":0.99121094,"words":[{"text":"Go","start":2785880,"end":2786160,"confidence":0.99121094,"speaker":"A"},{"text":"for","start":2786160,"end":2786320,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":2786320,"end":2786600,"confidence":0.99853516,"speaker":"A"},{"text":"No,","start":2788440,"end":2788840,"confidence":0.95751953,"speaker":"B"},{"text":"there's","start":2789880,"end":2790319,"confidence":0.9355469,"speaker":"B"},{"text":"a","start":2790319,"end":2790440,"confidence":0.9995117,"speaker":"B"},{"text":"lot","start":2790440,"end":2790600,"confidence":0.9995117,"speaker":"B"},{"text":"there","start":2790600,"end":2790840,"confidence":0.99902344,"speaker":"B"},{"text":"to.","start":2790840,"end":2791160,"confidence":0.98828125,"speaker":"B"},{"text":"To","start":2791400,"end":2791720,"confidence":0.99902344,"speaker":"B"},{"text":"absorb.","start":2791720,"end":2792160,"confidence":0.99938965,"speaker":"B"},{"text":"But","start":2792160,"end":2792320,"confidence":0.9995117,"speaker":"B"},{"text":"I,","start":2792320,"end":2792600,"confidence":0.99121094,"speaker":"B"},{"text":"I","start":2792760,"end":2793120,"confidence":0.99658203,"speaker":"B"},{"text":"like","start":2793120,"end":2793400,"confidence":0.99902344,"speaker":"B"},{"text":"the","start":2793400,"end":2793640,"confidence":0.9995117,"speaker":"B"},{"text":"concept","start":2793640,"end":2794200,"confidence":0.976888,"speaker":"B"},{"text":"and","start":2794440,"end":2794720,"confidence":0.99560547,"speaker":"B"},{"text":"I","start":2794720,"end":2794840,"confidence":0.9995117,"speaker":"B"},{"text":"know","start":2794840,"end":2794960,"confidence":1,"speaker":"B"},{"text":"you've","start":2794960,"end":2795280,"confidence":0.99820966,"speaker":"B"},{"text":"been","start":2795280,"end":2795440,"confidence":0.9995117,"speaker":"B"},{"text":"working","start":2795440,"end":2795640,"confidence":0.9995117,"speaker":"B"},{"text":"on","start":2795640,"end":2795840,"confidence":0.9995117,"speaker":"B"},{"text":"this","start":2795840,"end":2796000,"confidence":0.9995117,"speaker":"B"},{"text":"for","start":2796000,"end":2796120,"confidence":0.9995117,"speaker":"B"},{"text":"a","start":2796120,"end":2796240,"confidence":0.99560547,"speaker":"B"},{"text":"while","start":2796240,"end":2796400,"confidence":1,"speaker":"B"},{"text":"and","start":2796400,"end":2796560,"confidence":0.9458008,"speaker":"B"},{"text":"I","start":2796560,"end":2796680,"confidence":0.9975586,"speaker":"B"},{"text":"always","start":2796680,"end":2796840,"confidence":0.99316406,"speaker":"B"},{"text":"thought","start":2796840,"end":2797040,"confidence":0.99853516,"speaker":"B"},{"text":"it","start":2797040,"end":2797160,"confidence":0.9970703,"speaker":"B"},{"text":"was","start":2797160,"end":2797280,"confidence":0.9951172,"speaker":"B"},{"text":"a","start":2797280,"end":2797440,"confidence":0.9663086,"speaker":"B"},{"text":"pretty","start":2797440,"end":2797640,"confidence":0.99869794,"speaker":"B"},{"text":"cool,","start":2797640,"end":2797960,"confidence":0.9980469,"speaker":"B"},{"text":"pretty","start":2799240,"end":2799560,"confidence":0.9943034,"speaker":"B"},{"text":"cool","start":2799560,"end":2799720,"confidence":0.88549805,"speaker":"B"},{"text":"idea","start":2800030,"end":2800350,"confidence":0.72094727,"speaker":"B"},{"text":"and","start":2800590,"end":2800910,"confidence":0.89404297,"speaker":"B"},{"text":"implementation","start":2800910,"end":2801630,"confidence":0.9941406,"speaker":"B"},{"text":"of","start":2801630,"end":2801910,"confidence":0.9770508,"speaker":"B"},{"text":"this.","start":2801910,"end":2802190,"confidence":0.9897461,"speaker":"B"},{"text":"Questions?","start":2802750,"end":2803470,"confidence":0.9904785,"speaker":"A"}]},{"text":"So with something like.","start":2808990,"end":2810030,"confidence":0.95214844,"words":[{"text":"So","start":2808990,"end":2809270,"confidence":0.95214844,"speaker":"C"},{"text":"with","start":2809270,"end":2809470,"confidence":0.9628906,"speaker":"C"},{"text":"something","start":2809470,"end":2809710,"confidence":0.9995117,"speaker":"C"},{"text":"like.","start":2809710,"end":2810030,"confidence":0.99853516,"speaker":"C"}]},{"text":"Accessing CloudKit through the web, is this setup more ideal for having your server do the authentication to CloudKit with Miskit or is miskit something that you could put into even like a client side, you know, like non Swift application or I guess not non Swift but like non like app application. I'm thinking in the context of like. A.","start":2814110,"end":2842049,"confidence":0.78027344,"words":[{"text":"Accessing","start":2814110,"end":2814750,"confidence":0.78027344,"speaker":"C"},{"text":"CloudKit","start":2814830,"end":2815430,"confidence":0.94202,"speaker":"C"},{"text":"through","start":2815430,"end":2815550,"confidence":0.9946289,"speaker":"C"},{"text":"the","start":2815550,"end":2815709,"confidence":0.99902344,"speaker":"C"},{"text":"web,","start":2815709,"end":2816109,"confidence":0.9916992,"speaker":"C"},{"text":"is","start":2816430,"end":2816830,"confidence":0.9995117,"speaker":"C"},{"text":"this","start":2817150,"end":2817510,"confidence":0.99853516,"speaker":"C"},{"text":"setup","start":2817510,"end":2817910,"confidence":0.95092773,"speaker":"C"},{"text":"more","start":2817910,"end":2818110,"confidence":0.9995117,"speaker":"C"},{"text":"ideal","start":2818110,"end":2818590,"confidence":0.9970703,"speaker":"C"},{"text":"for","start":2818670,"end":2819070,"confidence":0.9995117,"speaker":"C"},{"text":"having","start":2820270,"end":2820630,"confidence":0.9995117,"speaker":"C"},{"text":"your","start":2820630,"end":2820990,"confidence":1,"speaker":"C"},{"text":"server","start":2820990,"end":2821630,"confidence":1,"speaker":"C"},{"text":"do","start":2821870,"end":2822270,"confidence":0.9995117,"speaker":"C"},{"text":"the","start":2822670,"end":2822990,"confidence":0.9980469,"speaker":"C"},{"text":"authentication","start":2822990,"end":2823710,"confidence":1,"speaker":"C"},{"text":"to","start":2823950,"end":2824230,"confidence":0.9970703,"speaker":"C"},{"text":"CloudKit","start":2824230,"end":2824790,"confidence":0.9939,"speaker":"C"},{"text":"with","start":2824790,"end":2824950,"confidence":0.99560547,"speaker":"C"},{"text":"Miskit","start":2824950,"end":2825550,"confidence":0.9923096,"speaker":"C"},{"text":"or","start":2825970,"end":2826210,"confidence":0.9921875,"speaker":"C"},{"text":"is","start":2826290,"end":2826650,"confidence":0.9980469,"speaker":"C"},{"text":"miskit","start":2826650,"end":2827250,"confidence":0.93859863,"speaker":"C"},{"text":"something","start":2827250,"end":2827490,"confidence":0.99853516,"speaker":"C"},{"text":"that","start":2827490,"end":2827650,"confidence":0.99658203,"speaker":"C"},{"text":"you","start":2827650,"end":2827770,"confidence":0.9995117,"speaker":"C"},{"text":"could","start":2827770,"end":2827970,"confidence":0.9970703,"speaker":"C"},{"text":"put","start":2827970,"end":2828210,"confidence":0.9995117,"speaker":"C"},{"text":"into","start":2828210,"end":2828530,"confidence":0.99902344,"speaker":"C"},{"text":"even","start":2828530,"end":2828850,"confidence":0.99560547,"speaker":"C"},{"text":"like","start":2828850,"end":2829050,"confidence":0.9765625,"speaker":"C"},{"text":"a","start":2829050,"end":2829330,"confidence":0.5620117,"speaker":"C"},{"text":"client","start":2829330,"end":2829890,"confidence":0.9987793,"speaker":"C"},{"text":"side,","start":2830130,"end":2830530,"confidence":0.52978516,"speaker":"C"},{"text":"you","start":2832850,"end":2833170,"confidence":0.95751953,"speaker":"C"},{"text":"know,","start":2833170,"end":2833370,"confidence":0.9995117,"speaker":"C"},{"text":"like","start":2833370,"end":2833650,"confidence":0.98583984,"speaker":"C"},{"text":"non","start":2834690,"end":2835090,"confidence":0.99658203,"speaker":"C"},{"text":"Swift","start":2835810,"end":2836290,"confidence":0.99780273,"speaker":"C"},{"text":"application","start":2836290,"end":2836770,"confidence":0.9995117,"speaker":"C"},{"text":"or","start":2836770,"end":2837010,"confidence":0.9140625,"speaker":"C"},{"text":"I","start":2837010,"end":2837210,"confidence":0.6401367,"speaker":"C"},{"text":"guess","start":2837210,"end":2837490,"confidence":0.99975586,"speaker":"C"},{"text":"not","start":2837490,"end":2837730,"confidence":0.9628906,"speaker":"C"},{"text":"non","start":2837730,"end":2837930,"confidence":0.8105469,"speaker":"C"},{"text":"Swift","start":2837930,"end":2838250,"confidence":0.9489746,"speaker":"C"},{"text":"but","start":2838250,"end":2838410,"confidence":0.98876953,"speaker":"C"},{"text":"like","start":2838410,"end":2838610,"confidence":0.98583984,"speaker":"C"},{"text":"non","start":2838610,"end":2838930,"confidence":0.9560547,"speaker":"C"},{"text":"like","start":2839090,"end":2839410,"confidence":0.79785156,"speaker":"C"},{"text":"app","start":2839410,"end":2839690,"confidence":0.99609375,"speaker":"C"},{"text":"application.","start":2839690,"end":2840170,"confidence":0.99853516,"speaker":"C"},{"text":"I'm","start":2840170,"end":2840410,"confidence":0.99397784,"speaker":"C"},{"text":"thinking","start":2840410,"end":2840730,"confidence":0.8215332,"speaker":"C"},{"text":"in","start":2840730,"end":2840970,"confidence":0.6489258,"speaker":"C"},{"text":"the","start":2840970,"end":2841130,"confidence":0.9946289,"speaker":"C"},{"text":"context","start":2841130,"end":2841450,"confidence":0.98502606,"speaker":"C"},{"text":"of","start":2841450,"end":2841570,"confidence":0.99902344,"speaker":"C"},{"text":"like.","start":2841570,"end":2841730,"confidence":0.98876953,"speaker":"C"},{"text":"A.","start":2841730,"end":2842049,"confidence":0.71728516,"speaker":"A"}]},{"text":"I guess if I wanted to create a something accessing CloudKit that is not your typical Mac or iOS app. Can you be more specific? I'm looking into one. One approach would be browser extensions.","start":2845730,"end":2862560,"confidence":0.99658203,"words":[{"text":"I","start":2845730,"end":2845970,"confidence":0.99658203,"speaker":"C"},{"text":"guess","start":2845970,"end":2846170,"confidence":1,"speaker":"C"},{"text":"if","start":2846170,"end":2846290,"confidence":0.9970703,"speaker":"C"},{"text":"I","start":2846290,"end":2846410,"confidence":0.9995117,"speaker":"C"},{"text":"wanted","start":2846410,"end":2846730,"confidence":0.9848633,"speaker":"C"},{"text":"to","start":2846730,"end":2846930,"confidence":1,"speaker":"C"},{"text":"create","start":2846930,"end":2847250,"confidence":0.9995117,"speaker":"C"},{"text":"a","start":2847330,"end":2847730,"confidence":0.87939453,"speaker":"C"},{"text":"something","start":2849970,"end":2850290,"confidence":0.9970703,"speaker":"C"},{"text":"accessing","start":2850290,"end":2850810,"confidence":0.96655273,"speaker":"C"},{"text":"CloudKit","start":2850810,"end":2851330,"confidence":0.99853516,"speaker":"C"},{"text":"that","start":2851330,"end":2851490,"confidence":0.9995117,"speaker":"C"},{"text":"is","start":2851490,"end":2851610,"confidence":0.99902344,"speaker":"C"},{"text":"not","start":2851610,"end":2851810,"confidence":0.9995117,"speaker":"C"},{"text":"your","start":2851810,"end":2852010,"confidence":0.9995117,"speaker":"C"},{"text":"typical","start":2852010,"end":2852370,"confidence":1,"speaker":"C"},{"text":"Mac","start":2852370,"end":2852610,"confidence":0.99780273,"speaker":"C"},{"text":"or","start":2852610,"end":2852730,"confidence":0.9863281,"speaker":"C"},{"text":"iOS","start":2852730,"end":2853090,"confidence":0.9980469,"speaker":"C"},{"text":"app.","start":2853090,"end":2853410,"confidence":0.99853516,"speaker":"C"},{"text":"Can","start":2854880,"end":2855000,"confidence":0.9609375,"speaker":"A"},{"text":"you","start":2855000,"end":2855160,"confidence":0.8486328,"speaker":"A"},{"text":"be","start":2855160,"end":2855400,"confidence":0.9951172,"speaker":"A"},{"text":"more","start":2855400,"end":2855680,"confidence":1,"speaker":"A"},{"text":"specific?","start":2855680,"end":2856160,"confidence":0.99975586,"speaker":"A"},{"text":"I'm","start":2857840,"end":2858200,"confidence":0.99104816,"speaker":"C"},{"text":"looking","start":2858200,"end":2858480,"confidence":0.99902344,"speaker":"C"},{"text":"into","start":2858720,"end":2859120,"confidence":0.99560547,"speaker":"C"},{"text":"one.","start":2859280,"end":2859640,"confidence":0.45483398,"speaker":"C"},{"text":"One","start":2859640,"end":2859880,"confidence":1,"speaker":"C"},{"text":"approach","start":2859880,"end":2860120,"confidence":1,"speaker":"C"},{"text":"would","start":2860120,"end":2860400,"confidence":0.99560547,"speaker":"C"},{"text":"be","start":2860400,"end":2860720,"confidence":0.99853516,"speaker":"C"},{"text":"browser","start":2861600,"end":2862040,"confidence":0.9998372,"speaker":"C"},{"text":"extensions.","start":2862040,"end":2862560,"confidence":0.99869794,"speaker":"C"}]},{"text":"So for like a non Safari browser. Yes.","start":2865040,"end":2868240,"confidence":0.67871094,"words":[{"text":"So","start":2865040,"end":2865440,"confidence":0.67871094,"speaker":"A"},{"text":"for","start":2865680,"end":2866000,"confidence":0.9926758,"speaker":"A"},{"text":"like","start":2866000,"end":2866200,"confidence":0.9321289,"speaker":"A"},{"text":"a","start":2866200,"end":2866320,"confidence":0.99121094,"speaker":"A"},{"text":"non","start":2866320,"end":2866520,"confidence":0.99560547,"speaker":"A"},{"text":"Safari","start":2866520,"end":2867080,"confidence":0.9980469,"speaker":"A"},{"text":"browser.","start":2867080,"end":2867680,"confidence":0.99609375,"speaker":"A"},{"text":"Yes.","start":2867760,"end":2868240,"confidence":0.99121094,"speaker":"C"}]},{"text":"Yeah, this would be great. So basically the way you'd want that to work, like the sticky part to me would be getting the web authentication token. Other than that, like have at it.","start":2870400,"end":2881090,"confidence":0.9814453,"words":[{"text":"Yeah,","start":2870400,"end":2870720,"confidence":0.9814453,"speaker":"A"},{"text":"this","start":2870720,"end":2870840,"confidence":0.9975586,"speaker":"A"},{"text":"would","start":2870840,"end":2871000,"confidence":0.9975586,"speaker":"A"},{"text":"be","start":2871000,"end":2871160,"confidence":0.9995117,"speaker":"A"},{"text":"great.","start":2871160,"end":2871400,"confidence":1,"speaker":"A"},{"text":"So","start":2871400,"end":2871600,"confidence":0.96240234,"speaker":"A"},{"text":"basically","start":2871600,"end":2872000,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2873040,"end":2873320,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":2873320,"end":2873560,"confidence":0.9995117,"speaker":"A"},{"text":"you'd","start":2873560,"end":2873960,"confidence":0.98860675,"speaker":"A"},{"text":"want","start":2873960,"end":2874120,"confidence":1,"speaker":"A"},{"text":"that","start":2874120,"end":2874320,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2874320,"end":2874560,"confidence":0.99853516,"speaker":"A"},{"text":"work,","start":2874560,"end":2874880,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":2875040,"end":2875400,"confidence":0.73095703,"speaker":"A"},{"text":"the","start":2875400,"end":2875640,"confidence":0.9980469,"speaker":"A"},{"text":"sticky","start":2875640,"end":2876040,"confidence":0.9973958,"speaker":"A"},{"text":"part","start":2876040,"end":2876200,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2876200,"end":2876360,"confidence":0.9980469,"speaker":"A"},{"text":"me","start":2876360,"end":2876560,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":2876560,"end":2876760,"confidence":0.9980469,"speaker":"A"},{"text":"be","start":2876760,"end":2876920,"confidence":0.9995117,"speaker":"A"},{"text":"getting","start":2876920,"end":2877120,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":2877120,"end":2877320,"confidence":0.99902344,"speaker":"A"},{"text":"web","start":2877320,"end":2877560,"confidence":0.998291,"speaker":"A"},{"text":"authentication","start":2877560,"end":2878240,"confidence":0.92614746,"speaker":"A"},{"text":"token.","start":2878240,"end":2878640,"confidence":0.99934894,"speaker":"A"},{"text":"Other","start":2878640,"end":2878880,"confidence":0.99316406,"speaker":"A"},{"text":"than","start":2878880,"end":2879080,"confidence":0.99560547,"speaker":"A"},{"text":"that,","start":2879080,"end":2879360,"confidence":0.97509766,"speaker":"A"},{"text":"like","start":2879440,"end":2879840,"confidence":0.7050781,"speaker":"A"},{"text":"have","start":2880370,"end":2880530,"confidence":0.9765625,"speaker":"A"},{"text":"at","start":2880530,"end":2880770,"confidence":0.515625,"speaker":"A"},{"text":"it.","start":2880770,"end":2881090,"confidence":0.9980469,"speaker":"A"}]},{"text":"So I'm gonna, I'm gonna be devil's advocate. Why not just use the CloudKit JavaScript library. If it's an extension, my brain jumps to Swift first. Right. But it's the reason I'm asking that is like it's a, it's already a web extension.","start":2884610,"end":2900890,"confidence":0.97802734,"words":[{"text":"So","start":2884610,"end":2884890,"confidence":0.97802734,"speaker":"A"},{"text":"I'm","start":2884890,"end":2885050,"confidence":0.98339844,"speaker":"A"},{"text":"gonna,","start":2885050,"end":2885250,"confidence":0.8352051,"speaker":"A"},{"text":"I'm","start":2885250,"end":2885410,"confidence":0.9949544,"speaker":"A"},{"text":"gonna","start":2885410,"end":2885570,"confidence":0.9736328,"speaker":"A"},{"text":"be","start":2885570,"end":2885690,"confidence":0.99853516,"speaker":"A"},{"text":"devil's","start":2885690,"end":2886050,"confidence":0.9608154,"speaker":"A"},{"text":"advocate.","start":2886050,"end":2886610,"confidence":0.9995117,"speaker":"A"},{"text":"Why","start":2886690,"end":2887010,"confidence":0.99609375,"speaker":"A"},{"text":"not","start":2887010,"end":2887290,"confidence":1,"speaker":"A"},{"text":"just","start":2887290,"end":2887570,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":2887570,"end":2887810,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2887810,"end":2888090,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":2888090,"end":2888770,"confidence":0.87769,"speaker":"A"},{"text":"JavaScript","start":2888850,"end":2889730,"confidence":0.99454755,"speaker":"A"},{"text":"library.","start":2889730,"end":2890210,"confidence":0.8435872,"speaker":"A"},{"text":"If","start":2890210,"end":2890450,"confidence":0.5620117,"speaker":"C"},{"text":"it's","start":2890450,"end":2890690,"confidence":0.9998372,"speaker":"C"},{"text":"an","start":2890690,"end":2890890,"confidence":0.8232422,"speaker":"C"},{"text":"extension,","start":2890890,"end":2891490,"confidence":0.9998372,"speaker":"C"},{"text":"my","start":2892450,"end":2892770,"confidence":0.99853516,"speaker":"C"},{"text":"brain","start":2892770,"end":2893090,"confidence":1,"speaker":"C"},{"text":"jumps","start":2893090,"end":2893450,"confidence":0.9998372,"speaker":"C"},{"text":"to","start":2893450,"end":2893610,"confidence":0.9995117,"speaker":"C"},{"text":"Swift","start":2893610,"end":2893970,"confidence":0.9914551,"speaker":"C"},{"text":"first.","start":2893970,"end":2894290,"confidence":0.9975586,"speaker":"C"},{"text":"Right.","start":2895730,"end":2896129,"confidence":0.97021484,"speaker":"A"},{"text":"But","start":2896129,"end":2896410,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":2896410,"end":2896730,"confidence":0.96875,"speaker":"A"},{"text":"the","start":2896730,"end":2896970,"confidence":1,"speaker":"A"},{"text":"reason","start":2896970,"end":2897130,"confidence":0.99902344,"speaker":"A"},{"text":"I'm","start":2897130,"end":2897330,"confidence":0.9954427,"speaker":"A"},{"text":"asking","start":2897330,"end":2897610,"confidence":0.97094727,"speaker":"A"},{"text":"that","start":2897610,"end":2897810,"confidence":0.9765625,"speaker":"A"},{"text":"is","start":2897810,"end":2898090,"confidence":0.9980469,"speaker":"A"},{"text":"like","start":2898090,"end":2898370,"confidence":0.9921875,"speaker":"A"},{"text":"it's","start":2898370,"end":2898690,"confidence":0.9900716,"speaker":"A"},{"text":"a,","start":2898690,"end":2898930,"confidence":0.98291016,"speaker":"A"},{"text":"it's","start":2899410,"end":2899770,"confidence":0.9996745,"speaker":"A"},{"text":"already","start":2899770,"end":2899970,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2899970,"end":2900130,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":2900130,"end":2900410,"confidence":0.98535156,"speaker":"A"},{"text":"extension.","start":2900410,"end":2900890,"confidence":0.9998372,"speaker":"A"}]},{"text":"I would assume that is true. That it's 90 web based or JavaScript based. So that's where I'm just like, well, you may as well. Like, I would love. I don't want to.","start":2900890,"end":2911320,"confidence":0.98535156,"words":[{"text":"I","start":2900890,"end":2901010,"confidence":0.98535156,"speaker":"A"},{"text":"would","start":2901010,"end":2901130,"confidence":0.98095703,"speaker":"A"},{"text":"assume","start":2901130,"end":2901410,"confidence":0.8614909,"speaker":"A"},{"text":"that","start":2901410,"end":2901570,"confidence":0.5854492,"speaker":"A"},{"text":"is","start":2901570,"end":2901690,"confidence":0.80126953,"speaker":"A"},{"text":"true.","start":2901690,"end":2902050,"confidence":0.9968262,"speaker":"A"},{"text":"That","start":2902690,"end":2903090,"confidence":0.9941406,"speaker":"A"},{"text":"it's","start":2903090,"end":2903490,"confidence":0.98876953,"speaker":"A"},{"text":"90","start":2903490,"end":2903810,"confidence":0.99951,"speaker":"A"},{"text":"web","start":2904290,"end":2904650,"confidence":0.9995117,"speaker":"A"},{"text":"based","start":2904650,"end":2904930,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":2905090,"end":2905410,"confidence":0.99853516,"speaker":"A"},{"text":"JavaScript","start":2905410,"end":2906010,"confidence":0.998291,"speaker":"A"},{"text":"based.","start":2906010,"end":2906290,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":2907120,"end":2907200,"confidence":0.9707031,"speaker":"A"},{"text":"that's","start":2907200,"end":2907360,"confidence":0.99934894,"speaker":"A"},{"text":"where","start":2907360,"end":2907480,"confidence":0.9506836,"speaker":"A"},{"text":"I'm","start":2907480,"end":2907680,"confidence":0.99886066,"speaker":"A"},{"text":"just","start":2907680,"end":2907800,"confidence":0.99560547,"speaker":"A"},{"text":"like,","start":2907800,"end":2908000,"confidence":0.99121094,"speaker":"A"},{"text":"well,","start":2908000,"end":2908320,"confidence":0.9951172,"speaker":"A"},{"text":"you","start":2908320,"end":2908600,"confidence":0.99902344,"speaker":"A"},{"text":"may","start":2908600,"end":2908760,"confidence":0.9995117,"speaker":"A"},{"text":"as","start":2908760,"end":2908920,"confidence":0.9995117,"speaker":"A"},{"text":"well.","start":2908920,"end":2909200,"confidence":0.9995117,"speaker":"A"},{"text":"Like,","start":2909200,"end":2909600,"confidence":0.5307617,"speaker":"A"},{"text":"I","start":2909840,"end":2910120,"confidence":0.77685547,"speaker":"A"},{"text":"would","start":2910120,"end":2910280,"confidence":0.99609375,"speaker":"A"},{"text":"love.","start":2910280,"end":2910560,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":2910640,"end":2910880,"confidence":0.97021484,"speaker":"A"},{"text":"don't","start":2910880,"end":2911000,"confidence":0.9313151,"speaker":"A"},{"text":"want","start":2911000,"end":2911120,"confidence":0.9394531,"speaker":"A"},{"text":"to.","start":2911120,"end":2911320,"confidence":0.94433594,"speaker":"A"}]},{"text":"Like, I love tooting my own horn. Right. But like, like why not just. Unless you're.","start":2911320,"end":2917120,"confidence":0.81689453,"words":[{"text":"Like,","start":2911320,"end":2911560,"confidence":0.81689453,"speaker":"A"},{"text":"I","start":2911560,"end":2911680,"confidence":0.99658203,"speaker":"A"},{"text":"love","start":2911680,"end":2911800,"confidence":0.99365234,"speaker":"A"},{"text":"tooting","start":2911800,"end":2912160,"confidence":0.8005371,"speaker":"A"},{"text":"my","start":2912160,"end":2912320,"confidence":1,"speaker":"A"},{"text":"own","start":2912320,"end":2912480,"confidence":1,"speaker":"A"},{"text":"horn.","start":2912480,"end":2912800,"confidence":0.9995117,"speaker":"A"},{"text":"Right.","start":2912800,"end":2913040,"confidence":0.9838867,"speaker":"A"},{"text":"But","start":2913040,"end":2913280,"confidence":0.9951172,"speaker":"A"},{"text":"like,","start":2913280,"end":2913600,"confidence":0.94628906,"speaker":"A"},{"text":"like","start":2914880,"end":2915280,"confidence":0.82666016,"speaker":"A"},{"text":"why","start":2915280,"end":2915560,"confidence":0.9951172,"speaker":"A"},{"text":"not","start":2915560,"end":2915800,"confidence":0.87939453,"speaker":"A"},{"text":"just.","start":2915800,"end":2916160,"confidence":0.9975586,"speaker":"A"},{"text":"Unless","start":2916320,"end":2916720,"confidence":0.92749023,"speaker":"A"},{"text":"you're.","start":2916720,"end":2917120,"confidence":0.9876302,"speaker":"A"}]},{"text":"Unless you're like building a executable, I guess, or an app. Ish. And I guess another application for this would be doing CloudKit stuff server side and then providing my own API layer over it. Yep, yep. So that's.","start":2920720,"end":2939860,"confidence":0.998291,"words":[{"text":"Unless","start":2920720,"end":2921080,"confidence":0.998291,"speaker":"A"},{"text":"you're","start":2921080,"end":2921440,"confidence":0.90478516,"speaker":"A"},{"text":"like","start":2921440,"end":2921840,"confidence":0.94628906,"speaker":"A"},{"text":"building","start":2922000,"end":2922400,"confidence":1,"speaker":"A"},{"text":"a","start":2922480,"end":2922879,"confidence":0.6621094,"speaker":"A"},{"text":"executable,","start":2923040,"end":2923840,"confidence":0.9987793,"speaker":"A"},{"text":"I","start":2924160,"end":2924440,"confidence":0.99316406,"speaker":"A"},{"text":"guess,","start":2924440,"end":2924800,"confidence":1,"speaker":"A"},{"text":"or","start":2924800,"end":2925080,"confidence":0.9970703,"speaker":"A"},{"text":"an","start":2925080,"end":2925240,"confidence":0.9628906,"speaker":"A"},{"text":"app.","start":2925240,"end":2925480,"confidence":0.93652344,"speaker":"A"},{"text":"Ish.","start":2925480,"end":2925920,"confidence":0.7595215,"speaker":"A"},{"text":"And","start":2927760,"end":2928080,"confidence":0.9038086,"speaker":"C"},{"text":"I","start":2928080,"end":2928400,"confidence":0.64697266,"speaker":"C"},{"text":"guess","start":2928400,"end":2928800,"confidence":1,"speaker":"C"},{"text":"another","start":2928800,"end":2929120,"confidence":1,"speaker":"C"},{"text":"application","start":2929120,"end":2929760,"confidence":1,"speaker":"C"},{"text":"for","start":2929760,"end":2930000,"confidence":1,"speaker":"C"},{"text":"this","start":2930000,"end":2930240,"confidence":1,"speaker":"C"},{"text":"would","start":2930240,"end":2930560,"confidence":0.9995117,"speaker":"C"},{"text":"be","start":2930560,"end":2930960,"confidence":0.9995117,"speaker":"C"},{"text":"doing","start":2931680,"end":2932040,"confidence":0.9995117,"speaker":"C"},{"text":"CloudKit","start":2932040,"end":2932680,"confidence":0.99902344,"speaker":"C"},{"text":"stuff","start":2932680,"end":2933000,"confidence":0.9954427,"speaker":"C"},{"text":"server","start":2933000,"end":2933360,"confidence":0.9074707,"speaker":"C"},{"text":"side","start":2933360,"end":2933640,"confidence":1,"speaker":"C"},{"text":"and","start":2933640,"end":2934000,"confidence":0.9243164,"speaker":"C"},{"text":"then","start":2934000,"end":2934400,"confidence":0.9995117,"speaker":"C"},{"text":"providing","start":2934400,"end":2934880,"confidence":0.8515625,"speaker":"C"},{"text":"my","start":2934880,"end":2935120,"confidence":0.9995117,"speaker":"C"},{"text":"own","start":2935120,"end":2935400,"confidence":1,"speaker":"C"},{"text":"API","start":2935400,"end":2935920,"confidence":1,"speaker":"C"},{"text":"layer","start":2935920,"end":2936280,"confidence":0.9995117,"speaker":"C"},{"text":"over","start":2936280,"end":2936480,"confidence":1,"speaker":"C"},{"text":"it.","start":2936480,"end":2936800,"confidence":0.99853516,"speaker":"C"},{"text":"Yep,","start":2937660,"end":2938060,"confidence":0.8959961,"speaker":"A"},{"text":"yep.","start":2938220,"end":2938700,"confidence":0.7453613,"speaker":"A"},{"text":"So","start":2938940,"end":2939340,"confidence":0.9946289,"speaker":"A"},{"text":"that's.","start":2939340,"end":2939860,"confidence":0.9943034,"speaker":"A"}]},{"text":"Yeah. Are we talking private database or public database? Private. So in that case, basically like you'd have to go the Hard Twitch route and you would have to provide a way to get their web authentication token, essentially, if that makes sense. And then store it in Postgres or whatever the hell you want to do.","start":2939860,"end":2963260,"confidence":0.99316406,"words":[{"text":"Yeah.","start":2939860,"end":2940300,"confidence":0.99316406,"speaker":"A"},{"text":"Are","start":2940460,"end":2940700,"confidence":0.99658203,"speaker":"A"},{"text":"we","start":2940700,"end":2940820,"confidence":0.9995117,"speaker":"A"},{"text":"talking","start":2940820,"end":2941180,"confidence":0.9992676,"speaker":"A"},{"text":"private","start":2941340,"end":2941660,"confidence":0.99902344,"speaker":"A"},{"text":"database","start":2941660,"end":2942180,"confidence":0.9998372,"speaker":"A"},{"text":"or","start":2942180,"end":2942340,"confidence":0.9970703,"speaker":"A"},{"text":"public","start":2942340,"end":2942540,"confidence":0.9995117,"speaker":"A"},{"text":"database?","start":2942540,"end":2943180,"confidence":0.9995117,"speaker":"A"},{"text":"Private.","start":2943340,"end":2943740,"confidence":0.99609375,"speaker":"C"},{"text":"So","start":2945580,"end":2945820,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":2945820,"end":2945940,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":2945940,"end":2946140,"confidence":0.9995117,"speaker":"A"},{"text":"case,","start":2946140,"end":2946460,"confidence":1,"speaker":"A"},{"text":"basically","start":2946700,"end":2947340,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":2948060,"end":2948340,"confidence":0.99853516,"speaker":"A"},{"text":"you'd","start":2948340,"end":2948660,"confidence":0.99690753,"speaker":"A"},{"text":"have","start":2948660,"end":2948780,"confidence":1,"speaker":"A"},{"text":"to","start":2948780,"end":2948900,"confidence":1,"speaker":"A"},{"text":"go","start":2948900,"end":2949140,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2949140,"end":2949380,"confidence":0.99902344,"speaker":"A"},{"text":"Hard","start":2949380,"end":2949580,"confidence":0.8798828,"speaker":"A"},{"text":"Twitch","start":2949580,"end":2949940,"confidence":0.9433594,"speaker":"A"},{"text":"route","start":2949940,"end":2950300,"confidence":0.9946289,"speaker":"A"},{"text":"and","start":2951100,"end":2951500,"confidence":0.9951172,"speaker":"A"},{"text":"you","start":2952460,"end":2952740,"confidence":0.99853516,"speaker":"A"},{"text":"would","start":2952740,"end":2952979,"confidence":0.8515625,"speaker":"A"},{"text":"have","start":2952979,"end":2953219,"confidence":1,"speaker":"A"},{"text":"to","start":2953219,"end":2953380,"confidence":1,"speaker":"A"},{"text":"provide","start":2953380,"end":2953660,"confidence":1,"speaker":"A"},{"text":"a","start":2953900,"end":2954180,"confidence":0.9760742,"speaker":"A"},{"text":"way","start":2954180,"end":2954460,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":2955980,"end":2956260,"confidence":0.9975586,"speaker":"A"},{"text":"get","start":2956260,"end":2956420,"confidence":1,"speaker":"A"},{"text":"their","start":2956420,"end":2956580,"confidence":0.9921875,"speaker":"A"},{"text":"web","start":2956580,"end":2956820,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":2956820,"end":2957420,"confidence":0.9996338,"speaker":"A"},{"text":"token,","start":2957420,"end":2957980,"confidence":0.99820966,"speaker":"A"},{"text":"essentially,","start":2958460,"end":2959060,"confidence":0.9316406,"speaker":"A"},{"text":"if","start":2959060,"end":2959260,"confidence":0.9770508,"speaker":"A"},{"text":"that","start":2959260,"end":2959380,"confidence":0.9995117,"speaker":"A"},{"text":"makes","start":2959380,"end":2959540,"confidence":0.9970703,"speaker":"A"},{"text":"sense.","start":2959540,"end":2959900,"confidence":0.99853516,"speaker":"A"},{"text":"And","start":2960540,"end":2960820,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":2960820,"end":2961020,"confidence":0.99902344,"speaker":"A"},{"text":"store","start":2961020,"end":2961260,"confidence":0.99853516,"speaker":"A"},{"text":"it","start":2961260,"end":2961380,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":2961380,"end":2961540,"confidence":0.9980469,"speaker":"A"},{"text":"Postgres","start":2961540,"end":2962020,"confidence":0.98046875,"speaker":"A"},{"text":"or","start":2962020,"end":2962180,"confidence":0.9970703,"speaker":"A"},{"text":"whatever","start":2962180,"end":2962380,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2962380,"end":2962500,"confidence":0.99902344,"speaker":"A"},{"text":"hell","start":2962500,"end":2962700,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":2962700,"end":2962820,"confidence":0.9995117,"speaker":"A"},{"text":"want","start":2962820,"end":2962980,"confidence":0.97802734,"speaker":"A"},{"text":"to","start":2962980,"end":2963100,"confidence":0.9980469,"speaker":"A"},{"text":"do.","start":2963100,"end":2963260,"confidence":0.9995117,"speaker":"A"}]},{"text":"Like that's, that's the way I did it with Hard Twitch. But once you have that, you can do anything you want on the server with their private database, if that makes sense. It does. Yep. Yep.","start":2963260,"end":2975120,"confidence":0.99121094,"words":[{"text":"Like","start":2963260,"end":2963500,"confidence":0.99121094,"speaker":"A"},{"text":"that's,","start":2963500,"end":2963820,"confidence":0.98876953,"speaker":"A"},{"text":"that's","start":2963820,"end":2964060,"confidence":0.99658203,"speaker":"A"},{"text":"the","start":2964060,"end":2964140,"confidence":0.99902344,"speaker":"A"},{"text":"way","start":2964140,"end":2964220,"confidence":1,"speaker":"A"},{"text":"I","start":2964220,"end":2964340,"confidence":0.9995117,"speaker":"A"},{"text":"did","start":2964340,"end":2964460,"confidence":0.9941406,"speaker":"A"},{"text":"it","start":2964460,"end":2964540,"confidence":0.9946289,"speaker":"A"},{"text":"with","start":2964540,"end":2964660,"confidence":0.9995117,"speaker":"A"},{"text":"Hard","start":2964660,"end":2964820,"confidence":0.8378906,"speaker":"A"},{"text":"Twitch.","start":2964820,"end":2965260,"confidence":0.88256836,"speaker":"A"},{"text":"But","start":2966400,"end":2966480,"confidence":0.96484375,"speaker":"A"},{"text":"once","start":2966480,"end":2966600,"confidence":0.9897461,"speaker":"A"},{"text":"you","start":2966600,"end":2966760,"confidence":0.9946289,"speaker":"A"},{"text":"have","start":2966760,"end":2966880,"confidence":0.8364258,"speaker":"A"},{"text":"that,","start":2966880,"end":2967120,"confidence":0.5385742,"speaker":"A"},{"text":"you","start":2967120,"end":2967360,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2967360,"end":2967440,"confidence":0.99902344,"speaker":"A"},{"text":"do","start":2967440,"end":2967520,"confidence":0.9995117,"speaker":"A"},{"text":"anything","start":2967520,"end":2967760,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2967760,"end":2967880,"confidence":0.9970703,"speaker":"A"},{"text":"want","start":2967880,"end":2968080,"confidence":0.99658203,"speaker":"A"},{"text":"on","start":2968080,"end":2968280,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":2968280,"end":2968440,"confidence":0.99316406,"speaker":"A"},{"text":"server","start":2968440,"end":2968880,"confidence":0.99975586,"speaker":"A"},{"text":"with","start":2969200,"end":2969520,"confidence":0.9980469,"speaker":"A"},{"text":"their","start":2969520,"end":2969840,"confidence":0.98583984,"speaker":"A"},{"text":"private","start":2970240,"end":2970600,"confidence":0.99853516,"speaker":"A"},{"text":"database,","start":2970600,"end":2971200,"confidence":0.9996745,"speaker":"A"},{"text":"if","start":2971200,"end":2971400,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":2971400,"end":2971560,"confidence":0.9995117,"speaker":"A"},{"text":"makes","start":2971560,"end":2971720,"confidence":0.9970703,"speaker":"A"},{"text":"sense.","start":2971720,"end":2972080,"confidence":0.99902344,"speaker":"A"},{"text":"It","start":2972560,"end":2972840,"confidence":0.9692383,"speaker":"C"},{"text":"does.","start":2972840,"end":2973120,"confidence":0.9980469,"speaker":"C"},{"text":"Yep.","start":2973920,"end":2974480,"confidence":0.8156738,"speaker":"A"},{"text":"Yep.","start":2974560,"end":2975120,"confidence":0.7368164,"speaker":"A"}]},{"text":"A couple of things I wanted to bring up, so let's take a look.","start":2975920,"end":2979520,"confidence":0.5620117,"words":[{"text":"A","start":2975920,"end":2976160,"confidence":0.5620117,"speaker":"A"},{"text":"couple","start":2976160,"end":2976360,"confidence":0.99731445,"speaker":"A"},{"text":"of","start":2976360,"end":2976480,"confidence":0.9433594,"speaker":"A"},{"text":"things","start":2976480,"end":2976720,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2977040,"end":2977320,"confidence":0.9980469,"speaker":"A"},{"text":"wanted","start":2977320,"end":2977560,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":2977560,"end":2977720,"confidence":0.9995117,"speaker":"A"},{"text":"bring","start":2977720,"end":2977920,"confidence":1,"speaker":"A"},{"text":"up,","start":2977920,"end":2978240,"confidence":0.9995117,"speaker":"A"},{"text":"so","start":2978320,"end":2978640,"confidence":0.9765625,"speaker":"A"},{"text":"let's","start":2978640,"end":2978920,"confidence":0.99902344,"speaker":"A"},{"text":"take","start":2978920,"end":2979080,"confidence":1,"speaker":"A"},{"text":"a","start":2979080,"end":2979240,"confidence":1,"speaker":"A"},{"text":"look.","start":2979240,"end":2979520,"confidence":0.9995117,"speaker":"A"}]},{"text":"So part of my other presentation is working, talking about cross platform automation type stuff. And the one issue I've run into is. So it basically builds on everything. Right now.","start":2984000,"end":3001560,"confidence":0.95214844,"words":[{"text":"So","start":2984000,"end":2984400,"confidence":0.95214844,"speaker":"A"},{"text":"part","start":2986880,"end":2987160,"confidence":0.99902344,"speaker":"A"},{"text":"of","start":2987160,"end":2987280,"confidence":1,"speaker":"A"},{"text":"my","start":2987280,"end":2987400,"confidence":1,"speaker":"A"},{"text":"other","start":2987400,"end":2987640,"confidence":1,"speaker":"A"},{"text":"presentation","start":2987640,"end":2988400,"confidence":1,"speaker":"A"},{"text":"is","start":2988640,"end":2989040,"confidence":0.99853516,"speaker":"A"},{"text":"working,","start":2990000,"end":2990400,"confidence":0.87841797,"speaker":"A"},{"text":"talking","start":2990800,"end":2991160,"confidence":0.7766113,"speaker":"A"},{"text":"about","start":2991160,"end":2991440,"confidence":0.9951172,"speaker":"A"},{"text":"cross","start":2991640,"end":2991880,"confidence":0.998291,"speaker":"A"},{"text":"platform","start":2991880,"end":2992360,"confidence":0.8640137,"speaker":"A"},{"text":"automation","start":2992600,"end":2993320,"confidence":0.9996745,"speaker":"A"},{"text":"type","start":2993640,"end":2994000,"confidence":0.9980469,"speaker":"A"},{"text":"stuff.","start":2994000,"end":2994440,"confidence":1,"speaker":"A"},{"text":"And","start":2995560,"end":2995960,"confidence":0.9868164,"speaker":"A"},{"text":"the","start":2996440,"end":2996760,"confidence":0.9995117,"speaker":"A"},{"text":"one","start":2996760,"end":2997040,"confidence":1,"speaker":"A"},{"text":"issue","start":2997040,"end":2997400,"confidence":0.9995117,"speaker":"A"},{"text":"I've","start":2997400,"end":2997840,"confidence":0.9972331,"speaker":"A"},{"text":"run","start":2997840,"end":2998040,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":2998040,"end":2998360,"confidence":1,"speaker":"A"},{"text":"is.","start":2998440,"end":2998840,"confidence":0.9926758,"speaker":"A"},{"text":"So","start":2998920,"end":2999200,"confidence":0.9921875,"speaker":"A"},{"text":"it","start":2999200,"end":2999360,"confidence":0.9916992,"speaker":"A"},{"text":"basically","start":2999360,"end":2999800,"confidence":0.99975586,"speaker":"A"},{"text":"builds","start":2999800,"end":3000160,"confidence":0.9992676,"speaker":"A"},{"text":"on","start":3000160,"end":3000360,"confidence":0.9995117,"speaker":"A"},{"text":"everything.","start":3000360,"end":3000680,"confidence":1,"speaker":"A"},{"text":"Right","start":3000920,"end":3001240,"confidence":0.9995117,"speaker":"A"},{"text":"now.","start":3001240,"end":3001560,"confidence":0.9995117,"speaker":"A"}]},{"text":"I'm going to share something. Hey guys, I got to drop. But it was good presentation, Leo. Thank you. Yeah, yeah.","start":3007560,"end":3015560,"confidence":0.9977214,"words":[{"text":"I'm","start":3007560,"end":3007880,"confidence":0.9977214,"speaker":"A"},{"text":"going","start":3007880,"end":3007960,"confidence":0.6772461,"speaker":"A"},{"text":"to","start":3007960,"end":3008080,"confidence":0.9975586,"speaker":"A"},{"text":"share","start":3008080,"end":3008320,"confidence":0.9995117,"speaker":"A"},{"text":"something.","start":3008320,"end":3008680,"confidence":0.9995117,"speaker":"A"},{"text":"Hey","start":3009880,"end":3010200,"confidence":0.99609375,"speaker":"B"},{"text":"guys,","start":3010200,"end":3010520,"confidence":0.99902344,"speaker":"B"},{"text":"I","start":3011000,"end":3011240,"confidence":0.9770508,"speaker":"B"},{"text":"got","start":3011240,"end":3011320,"confidence":0.99609375,"speaker":"B"},{"text":"to","start":3011320,"end":3011400,"confidence":0.44458008,"speaker":"B"},{"text":"drop.","start":3011400,"end":3011720,"confidence":0.9885254,"speaker":"B"},{"text":"But","start":3011800,"end":3012160,"confidence":0.98291016,"speaker":"B"},{"text":"it","start":3012160,"end":3012400,"confidence":0.9995117,"speaker":"B"},{"text":"was","start":3012400,"end":3012680,"confidence":0.9995117,"speaker":"B"},{"text":"good","start":3012680,"end":3013000,"confidence":0.9995117,"speaker":"B"},{"text":"presentation,","start":3013000,"end":3013480,"confidence":0.9995117,"speaker":"B"},{"text":"Leo.","start":3013480,"end":3014040,"confidence":0.9987793,"speaker":"B"},{"text":"Thank","start":3014040,"end":3014400,"confidence":0.99975586,"speaker":"B"},{"text":"you.","start":3014400,"end":3014680,"confidence":0.9975586,"speaker":"B"},{"text":"Yeah,","start":3014840,"end":3015240,"confidence":0.99088544,"speaker":"A"},{"text":"yeah.","start":3015240,"end":3015560,"confidence":0.9458008,"speaker":"A"}]},{"text":"If I have more questions, if you have any feedback, just hit me up on Slack. Sounds good. Cool, thank you. Thank you so much for helping me set this up. Yeah, talk to you later.","start":3015560,"end":3024710,"confidence":0.88964844,"words":[{"text":"If","start":3015560,"end":3015720,"confidence":0.88964844,"speaker":"A"},{"text":"I","start":3015720,"end":3015840,"confidence":0.98876953,"speaker":"A"},{"text":"have","start":3015840,"end":3015960,"confidence":0.9169922,"speaker":"A"},{"text":"more","start":3015960,"end":3016040,"confidence":0.97265625,"speaker":"A"},{"text":"questions,","start":3016040,"end":3016320,"confidence":0.95996094,"speaker":"A"},{"text":"if","start":3016320,"end":3016440,"confidence":0.9589844,"speaker":"A"},{"text":"you","start":3016440,"end":3016520,"confidence":0.9951172,"speaker":"A"},{"text":"have","start":3016520,"end":3016640,"confidence":0.9980469,"speaker":"A"},{"text":"any","start":3016640,"end":3016800,"confidence":0.9995117,"speaker":"A"},{"text":"feedback,","start":3016800,"end":3017160,"confidence":0.9996338,"speaker":"A"},{"text":"just","start":3017160,"end":3017360,"confidence":0.9995117,"speaker":"A"},{"text":"hit","start":3017360,"end":3017520,"confidence":1,"speaker":"A"},{"text":"me","start":3017520,"end":3017640,"confidence":1,"speaker":"A"},{"text":"up","start":3017640,"end":3017760,"confidence":1,"speaker":"A"},{"text":"on","start":3017760,"end":3018040,"confidence":0.99658203,"speaker":"A"},{"text":"Slack.","start":3018950,"end":3019350,"confidence":0.89697266,"speaker":"A"},{"text":"Sounds","start":3019590,"end":3019990,"confidence":0.9978841,"speaker":"B"},{"text":"good.","start":3019990,"end":3020150,"confidence":0.9980469,"speaker":"B"},{"text":"Cool,","start":3020150,"end":3020470,"confidence":0.9345703,"speaker":"A"},{"text":"thank","start":3020470,"end":3020750,"confidence":0.7890625,"speaker":"A"},{"text":"you.","start":3020750,"end":3020950,"confidence":0.99316406,"speaker":"A"},{"text":"Thank","start":3020950,"end":3021230,"confidence":0.94628906,"speaker":"A"},{"text":"you","start":3021230,"end":3021350,"confidence":0.9995117,"speaker":"A"},{"text":"so","start":3021350,"end":3021470,"confidence":0.99853516,"speaker":"A"},{"text":"much","start":3021470,"end":3021590,"confidence":1,"speaker":"A"},{"text":"for","start":3021590,"end":3021710,"confidence":0.9995117,"speaker":"A"},{"text":"helping","start":3021710,"end":3021950,"confidence":0.99975586,"speaker":"A"},{"text":"me","start":3021950,"end":3022150,"confidence":0.81103516,"speaker":"A"},{"text":"set","start":3022150,"end":3022350,"confidence":0.96240234,"speaker":"A"},{"text":"this","start":3022350,"end":3022510,"confidence":0.99365234,"speaker":"A"},{"text":"up.","start":3022510,"end":3022790,"confidence":0.99902344,"speaker":"A"},{"text":"Yeah,","start":3023590,"end":3023990,"confidence":0.95214844,"speaker":"A"},{"text":"talk","start":3023990,"end":3024190,"confidence":0.9824219,"speaker":"A"},{"text":"to","start":3024190,"end":3024350,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":3024350,"end":3024470,"confidence":0.99658203,"speaker":"A"},{"text":"later.","start":3024470,"end":3024710,"confidence":0.9838867,"speaker":"A"}]},{"text":"Thank you. Bye bye.","start":3024950,"end":3025910,"confidence":0.9968262,"words":[{"text":"Thank","start":3024950,"end":3025230,"confidence":0.9968262,"speaker":"B"},{"text":"you.","start":3025230,"end":3025350,"confidence":0.99902344,"speaker":"B"},{"text":"Bye","start":3025350,"end":3025590,"confidence":0.9824219,"speaker":"B"},{"text":"bye.","start":3025590,"end":3025910,"confidence":0.99316406,"speaker":"B"}]},{"text":"Yeah, so if you had something else to show, I'm happy to look for. I'm here for a few more minutes as well. Yeah, yeah, yeah.","start":3028870,"end":3034390,"confidence":0.88216144,"words":[{"text":"Yeah,","start":3028870,"end":3029190,"confidence":0.88216144,"speaker":"C"},{"text":"so","start":3029190,"end":3029310,"confidence":0.91308594,"speaker":"C"},{"text":"if","start":3029310,"end":3029430,"confidence":0.99609375,"speaker":"C"},{"text":"you","start":3029430,"end":3029510,"confidence":0.99365234,"speaker":"C"},{"text":"had","start":3029510,"end":3029630,"confidence":0.9638672,"speaker":"C"},{"text":"something","start":3029630,"end":3029830,"confidence":0.9995117,"speaker":"C"},{"text":"else","start":3029830,"end":3030070,"confidence":0.99975586,"speaker":"C"},{"text":"to","start":3030070,"end":3030190,"confidence":0.99853516,"speaker":"C"},{"text":"show,","start":3030190,"end":3030350,"confidence":0.99902344,"speaker":"C"},{"text":"I'm","start":3030350,"end":3030550,"confidence":0.99869794,"speaker":"C"},{"text":"happy","start":3030550,"end":3030750,"confidence":0.9995117,"speaker":"C"},{"text":"to","start":3030750,"end":3030990,"confidence":0.6503906,"speaker":"C"},{"text":"look","start":3030990,"end":3031230,"confidence":0.97021484,"speaker":"C"},{"text":"for.","start":3031230,"end":3031430,"confidence":0.79541016,"speaker":"C"},{"text":"I'm","start":3031430,"end":3031670,"confidence":0.99104816,"speaker":"C"},{"text":"here","start":3031670,"end":3031790,"confidence":0.9995117,"speaker":"C"},{"text":"for","start":3031790,"end":3031910,"confidence":0.9995117,"speaker":"C"},{"text":"a","start":3031910,"end":3031990,"confidence":0.9980469,"speaker":"C"},{"text":"few","start":3031990,"end":3032110,"confidence":0.9995117,"speaker":"C"},{"text":"more","start":3032110,"end":3032270,"confidence":0.9995117,"speaker":"C"},{"text":"minutes","start":3032270,"end":3032510,"confidence":0.9987793,"speaker":"C"},{"text":"as","start":3032510,"end":3032670,"confidence":0.99853516,"speaker":"C"},{"text":"well.","start":3032670,"end":3032950,"confidence":0.99902344,"speaker":"C"},{"text":"Yeah,","start":3033590,"end":3033910,"confidence":0.96402997,"speaker":"A"},{"text":"yeah,","start":3033910,"end":3034070,"confidence":0.90755206,"speaker":"A"},{"text":"yeah.","start":3034070,"end":3034390,"confidence":0.8152669,"speaker":"A"}]},{"text":"So I have the workflow working here and it does Ubuntu, it does Windows, it does Android. So all that stuff is available to you. I would never recommend using Miskit on an Apple platform for obvious reasons, like what's the point? True. Unless there's something special that I provide that CloudKit doesn't like, I don't get it.","start":3038790,"end":3060320,"confidence":0.94628906,"words":[{"text":"So","start":3038790,"end":3039110,"confidence":0.94628906,"speaker":"A"},{"text":"I","start":3039110,"end":3039350,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":3039350,"end":3039630,"confidence":1,"speaker":"A"},{"text":"the","start":3039630,"end":3039870,"confidence":0.9980469,"speaker":"A"},{"text":"workflow","start":3039870,"end":3040350,"confidence":0.9995117,"speaker":"A"},{"text":"working","start":3040350,"end":3040630,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":3041190,"end":3041590,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":3041670,"end":3041950,"confidence":0.9892578,"speaker":"A"},{"text":"it","start":3041950,"end":3042070,"confidence":0.9995117,"speaker":"A"},{"text":"does","start":3042070,"end":3042270,"confidence":0.99902344,"speaker":"A"},{"text":"Ubuntu,","start":3042270,"end":3043110,"confidence":0.9856445,"speaker":"A"},{"text":"it","start":3044080,"end":3044200,"confidence":0.97216797,"speaker":"A"},{"text":"does","start":3044200,"end":3044400,"confidence":0.99853516,"speaker":"A"},{"text":"Windows,","start":3044400,"end":3044960,"confidence":0.9944661,"speaker":"A"},{"text":"it","start":3045120,"end":3045400,"confidence":0.99365234,"speaker":"A"},{"text":"does","start":3045400,"end":3045600,"confidence":0.98779297,"speaker":"A"},{"text":"Android.","start":3045600,"end":3046120,"confidence":0.9943034,"speaker":"A"},{"text":"So","start":3046120,"end":3046360,"confidence":0.98046875,"speaker":"A"},{"text":"all","start":3046360,"end":3046480,"confidence":0.99853516,"speaker":"A"},{"text":"that","start":3046480,"end":3046600,"confidence":0.9975586,"speaker":"A"},{"text":"stuff","start":3046600,"end":3046880,"confidence":0.90494794,"speaker":"A"},{"text":"is","start":3046880,"end":3047080,"confidence":0.9995117,"speaker":"A"},{"text":"available","start":3047080,"end":3047360,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3047440,"end":3047720,"confidence":0.99902344,"speaker":"A"},{"text":"you.","start":3047720,"end":3048000,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3048640,"end":3048960,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":3048960,"end":3049200,"confidence":0.9995117,"speaker":"A"},{"text":"never","start":3049200,"end":3049440,"confidence":1,"speaker":"A"},{"text":"recommend","start":3049440,"end":3049920,"confidence":0.9998372,"speaker":"A"},{"text":"using","start":3049920,"end":3050240,"confidence":0.99902344,"speaker":"A"},{"text":"Miskit","start":3050240,"end":3050920,"confidence":0.9777832,"speaker":"A"},{"text":"on","start":3050920,"end":3051160,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":3051160,"end":3051320,"confidence":0.99902344,"speaker":"A"},{"text":"Apple","start":3051320,"end":3051560,"confidence":1,"speaker":"A"},{"text":"platform","start":3051560,"end":3052040,"confidence":0.9992676,"speaker":"A"},{"text":"for","start":3052040,"end":3052280,"confidence":0.9995117,"speaker":"A"},{"text":"obvious","start":3052280,"end":3052640,"confidence":0.99975586,"speaker":"A"},{"text":"reasons,","start":3052640,"end":3053200,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":3053280,"end":3053600,"confidence":0.9238281,"speaker":"A"},{"text":"what's","start":3053600,"end":3053840,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3053840,"end":3053960,"confidence":0.9995117,"speaker":"A"},{"text":"point?","start":3053960,"end":3054240,"confidence":0.99902344,"speaker":"A"},{"text":"True.","start":3055600,"end":3056080,"confidence":0.9099121,"speaker":"C"},{"text":"Unless","start":3056080,"end":3056440,"confidence":0.99609375,"speaker":"A"},{"text":"there's","start":3056440,"end":3056720,"confidence":0.9946289,"speaker":"A"},{"text":"something","start":3056720,"end":3056920,"confidence":1,"speaker":"A"},{"text":"special","start":3056920,"end":3057240,"confidence":1,"speaker":"A"},{"text":"that","start":3057240,"end":3057480,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":3057480,"end":3057640,"confidence":0.9995117,"speaker":"A"},{"text":"provide","start":3057640,"end":3057880,"confidence":1,"speaker":"A"},{"text":"that","start":3057880,"end":3058160,"confidence":0.9897461,"speaker":"A"},{"text":"CloudKit","start":3058160,"end":3058760,"confidence":0.89551,"speaker":"A"},{"text":"doesn't","start":3058760,"end":3059040,"confidence":0.96777344,"speaker":"A"},{"text":"like,","start":3059040,"end":3059360,"confidence":0.83496094,"speaker":"A"},{"text":"I","start":3059440,"end":3059680,"confidence":0.99560547,"speaker":"A"},{"text":"don't","start":3059680,"end":3059920,"confidence":0.8590495,"speaker":"A"},{"text":"get","start":3059920,"end":3060039,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":3060039,"end":3060320,"confidence":0.9980469,"speaker":"A"}]},{"text":"Right. But we have an issue. So I just started dabbling. I haven't really done anything with wasm, but I did definitely try. Like I added support for WASM in my, in my Swift build action.","start":3060480,"end":3074890,"confidence":0.8925781,"words":[{"text":"Right.","start":3060480,"end":3060880,"confidence":0.8925781,"speaker":"C"},{"text":"But","start":3061200,"end":3061600,"confidence":0.9941406,"speaker":"A"},{"text":"we","start":3062560,"end":3062880,"confidence":0.9926758,"speaker":"A"},{"text":"have","start":3062880,"end":3063200,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":3063200,"end":3063520,"confidence":0.9770508,"speaker":"A"},{"text":"issue.","start":3063520,"end":3063840,"confidence":0.9765625,"speaker":"A"},{"text":"So","start":3063920,"end":3064200,"confidence":0.9794922,"speaker":"A"},{"text":"I","start":3064200,"end":3064360,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":3064360,"end":3064560,"confidence":0.99902344,"speaker":"A"},{"text":"started","start":3064560,"end":3064840,"confidence":0.9995117,"speaker":"A"},{"text":"dabbling.","start":3064840,"end":3065440,"confidence":0.91918945,"speaker":"A"},{"text":"I","start":3066000,"end":3066280,"confidence":0.609375,"speaker":"A"},{"text":"haven't","start":3066280,"end":3066520,"confidence":0.9489746,"speaker":"A"},{"text":"really","start":3066520,"end":3066800,"confidence":0.9975586,"speaker":"A"},{"text":"done","start":3066960,"end":3067280,"confidence":1,"speaker":"A"},{"text":"anything","start":3067280,"end":3067640,"confidence":1,"speaker":"A"},{"text":"with","start":3067640,"end":3067840,"confidence":0.9995117,"speaker":"A"},{"text":"wasm,","start":3067840,"end":3068480,"confidence":0.6376953,"speaker":"A"},{"text":"but","start":3069450,"end":3069530,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3069530,"end":3069650,"confidence":0.9980469,"speaker":"A"},{"text":"did","start":3069650,"end":3069810,"confidence":0.99853516,"speaker":"A"},{"text":"definitely","start":3069810,"end":3070210,"confidence":0.83239746,"speaker":"A"},{"text":"try.","start":3070210,"end":3070570,"confidence":0.99902344,"speaker":"A"},{"text":"Like","start":3070570,"end":3070850,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3070850,"end":3071010,"confidence":0.99609375,"speaker":"A"},{"text":"added","start":3071010,"end":3071250,"confidence":0.99902344,"speaker":"A"},{"text":"support","start":3071250,"end":3071530,"confidence":0.99853516,"speaker":"A"},{"text":"for","start":3071530,"end":3071730,"confidence":0.99853516,"speaker":"A"},{"text":"WASM","start":3071730,"end":3072250,"confidence":0.5599365,"speaker":"A"},{"text":"in","start":3072250,"end":3072450,"confidence":0.9560547,"speaker":"A"},{"text":"my,","start":3072450,"end":3072730,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":3072730,"end":3073050,"confidence":0.9980469,"speaker":"A"},{"text":"my","start":3073050,"end":3073370,"confidence":1,"speaker":"A"},{"text":"Swift","start":3073690,"end":3074210,"confidence":0.9980469,"speaker":"A"},{"text":"build","start":3074210,"end":3074530,"confidence":0.99609375,"speaker":"A"},{"text":"action.","start":3074530,"end":3074890,"confidence":0.99902344,"speaker":"A"}]},{"text":"The thing about WASA is it does not provide. It doesn't have a transport available. So we talked about transports, I think. Did you hear about that part about the Open API generator and transports? I think I was coming in at that point.","start":3077210,"end":3093690,"confidence":0.99121094,"words":[{"text":"The","start":3077210,"end":3077490,"confidence":0.99121094,"speaker":"A"},{"text":"thing","start":3077490,"end":3077650,"confidence":0.9980469,"speaker":"A"},{"text":"about","start":3077650,"end":3077930,"confidence":0.9995117,"speaker":"A"},{"text":"WASA","start":3077930,"end":3078650,"confidence":0.66918945,"speaker":"A"},{"text":"is","start":3078650,"end":3078850,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":3078850,"end":3079010,"confidence":0.99853516,"speaker":"A"},{"text":"does","start":3079010,"end":3079210,"confidence":0.99853516,"speaker":"A"},{"text":"not","start":3079210,"end":3079410,"confidence":0.99560547,"speaker":"A"},{"text":"provide.","start":3079410,"end":3079690,"confidence":0.99902344,"speaker":"A"},{"text":"It","start":3079770,"end":3080050,"confidence":0.99609375,"speaker":"A"},{"text":"doesn't","start":3080050,"end":3080290,"confidence":0.9978841,"speaker":"A"},{"text":"have","start":3080290,"end":3080410,"confidence":1,"speaker":"A"},{"text":"a","start":3080410,"end":3080530,"confidence":0.99853516,"speaker":"A"},{"text":"transport","start":3080530,"end":3081050,"confidence":0.99853516,"speaker":"A"},{"text":"available.","start":3081130,"end":3081530,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":3082570,"end":3082850,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":3082850,"end":3083050,"confidence":0.99853516,"speaker":"A"},{"text":"talked","start":3083050,"end":3083290,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":3083290,"end":3083490,"confidence":0.9995117,"speaker":"A"},{"text":"transports,","start":3083490,"end":3084410,"confidence":0.9938151,"speaker":"A"},{"text":"I","start":3086010,"end":3086250,"confidence":0.9770508,"speaker":"A"},{"text":"think.","start":3086250,"end":3086490,"confidence":0.9980469,"speaker":"A"},{"text":"Did","start":3086570,"end":3086850,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":3086850,"end":3087010,"confidence":1,"speaker":"A"},{"text":"hear","start":3087010,"end":3087170,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":3087170,"end":3087330,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":3087330,"end":3087530,"confidence":0.9970703,"speaker":"A"},{"text":"part","start":3087530,"end":3087770,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":3087770,"end":3087970,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3087970,"end":3088090,"confidence":0.9995117,"speaker":"A"},{"text":"Open","start":3088090,"end":3088250,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":3088250,"end":3088770,"confidence":0.7873535,"speaker":"A"},{"text":"generator","start":3088770,"end":3089170,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":3089170,"end":3089330,"confidence":0.95751953,"speaker":"A"},{"text":"transports?","start":3089330,"end":3090090,"confidence":0.8383789,"speaker":"A"},{"text":"I","start":3091370,"end":3091770,"confidence":0.9667969,"speaker":"C"},{"text":"think","start":3091850,"end":3092170,"confidence":0.9995117,"speaker":"C"},{"text":"I","start":3092170,"end":3092370,"confidence":0.9970703,"speaker":"C"},{"text":"was","start":3092370,"end":3092570,"confidence":1,"speaker":"C"},{"text":"coming","start":3092570,"end":3092810,"confidence":0.9995117,"speaker":"C"},{"text":"in","start":3092810,"end":3093010,"confidence":0.9980469,"speaker":"C"},{"text":"at","start":3093010,"end":3093130,"confidence":1,"speaker":"C"},{"text":"that","start":3093130,"end":3093330,"confidence":0.99560547,"speaker":"C"},{"text":"point.","start":3093330,"end":3093690,"confidence":0.9980469,"speaker":"C"}]},{"text":"Okay. When you create a client, so underneath the client you have what's called a client transport. This is so underneath this client, this is an abstraction layer above. So this is not the right one. Where's the public one?","start":3094410,"end":3113390,"confidence":0.92496747,"words":[{"text":"Okay.","start":3094410,"end":3094920,"confidence":0.92496747,"speaker":"A"},{"text":"When","start":3095630,"end":3095750,"confidence":0.71191406,"speaker":"A"},{"text":"you","start":3095750,"end":3095910,"confidence":0.93408203,"speaker":"A"},{"text":"create","start":3095910,"end":3096070,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":3096070,"end":3096230,"confidence":0.9951172,"speaker":"A"},{"text":"client,","start":3096230,"end":3096670,"confidence":0.9995117,"speaker":"A"},{"text":"so","start":3097630,"end":3097910,"confidence":0.9794922,"speaker":"A"},{"text":"underneath","start":3097910,"end":3098310,"confidence":0.9996745,"speaker":"A"},{"text":"the","start":3098310,"end":3098470,"confidence":0.9995117,"speaker":"A"},{"text":"client","start":3098470,"end":3098910,"confidence":1,"speaker":"A"},{"text":"you","start":3102350,"end":3102630,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":3102630,"end":3102910,"confidence":1,"speaker":"A"},{"text":"what's","start":3102910,"end":3103230,"confidence":0.99934894,"speaker":"A"},{"text":"called","start":3103230,"end":3103350,"confidence":1,"speaker":"A"},{"text":"a","start":3103350,"end":3103510,"confidence":0.7114258,"speaker":"A"},{"text":"client","start":3103510,"end":3103790,"confidence":0.81811523,"speaker":"A"},{"text":"transport.","start":3103790,"end":3104430,"confidence":0.9987793,"speaker":"A"},{"text":"This","start":3104670,"end":3104950,"confidence":0.8666992,"speaker":"A"},{"text":"is","start":3104950,"end":3105230,"confidence":0.99902344,"speaker":"A"},{"text":"so","start":3105630,"end":3105910,"confidence":0.9921875,"speaker":"A"},{"text":"underneath","start":3105910,"end":3106430,"confidence":0.90999347,"speaker":"A"},{"text":"this","start":3106670,"end":3106990,"confidence":0.99902344,"speaker":"A"},{"text":"client,","start":3106990,"end":3107310,"confidence":0.9941406,"speaker":"A"},{"text":"this","start":3107310,"end":3107510,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":3107510,"end":3107630,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":3107630,"end":3107750,"confidence":0.99902344,"speaker":"A"},{"text":"abstraction","start":3107750,"end":3108350,"confidence":0.99975586,"speaker":"A"},{"text":"layer","start":3108350,"end":3108750,"confidence":0.9995117,"speaker":"A"},{"text":"above.","start":3108750,"end":3109150,"confidence":0.8647461,"speaker":"A"},{"text":"So","start":3109870,"end":3110190,"confidence":0.58496094,"speaker":"A"},{"text":"this","start":3110190,"end":3110390,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3110390,"end":3110550,"confidence":0.99902344,"speaker":"A"},{"text":"not","start":3110550,"end":3110829,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":3110829,"end":3111109,"confidence":0.9995117,"speaker":"A"},{"text":"right","start":3111109,"end":3111270,"confidence":0.99609375,"speaker":"A"},{"text":"one.","start":3111270,"end":3111550,"confidence":0.98339844,"speaker":"A"},{"text":"Where's","start":3112190,"end":3112630,"confidence":0.98323566,"speaker":"A"},{"text":"the","start":3112630,"end":3112790,"confidence":1,"speaker":"A"},{"text":"public","start":3112790,"end":3113030,"confidence":0.9995117,"speaker":"A"},{"text":"one?","start":3113030,"end":3113390,"confidence":0.9916992,"speaker":"A"}]},{"text":"But anyway, there is here CloudKit service maybe.","start":3120680,"end":3126920,"confidence":0.99560547,"words":[{"text":"But","start":3120680,"end":3120800,"confidence":0.99560547,"speaker":"A"},{"text":"anyway,","start":3120800,"end":3121160,"confidence":0.9995117,"speaker":"A"},{"text":"there","start":3121160,"end":3121400,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":3121400,"end":3121720,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":3125080,"end":3125440,"confidence":0.97509766,"speaker":"A"},{"text":"CloudKit","start":3125440,"end":3126040,"confidence":0.98950195,"speaker":"A"},{"text":"service","start":3126040,"end":3126360,"confidence":0.9975586,"speaker":"A"},{"text":"maybe.","start":3126360,"end":3126920,"confidence":0.9958496,"speaker":"A"}]},{"text":"Yeah, here we go. So the CloudKit service has a client and part of the client is being able to say what transport you use in Open API. And there's two transports available right now. One is, one is your regular URL session for clients, which. That makes sense.","start":3129560,"end":3160930,"confidence":0.87158203,"words":[{"text":"Yeah,","start":3129560,"end":3129920,"confidence":0.87158203,"speaker":"A"},{"text":"here","start":3129920,"end":3130080,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":3130080,"end":3130240,"confidence":1,"speaker":"A"},{"text":"go.","start":3130240,"end":3130520,"confidence":1,"speaker":"A"},{"text":"So","start":3131320,"end":3131560,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":3131560,"end":3131640,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":3131640,"end":3132280,"confidence":0.9147949,"speaker":"A"},{"text":"service","start":3132440,"end":3132840,"confidence":0.99609375,"speaker":"A"},{"text":"has","start":3133320,"end":3133640,"confidence":1,"speaker":"A"},{"text":"a","start":3133640,"end":3133840,"confidence":0.9995117,"speaker":"A"},{"text":"client","start":3133840,"end":3134360,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":3135320,"end":3135640,"confidence":0.984375,"speaker":"A"},{"text":"part","start":3135640,"end":3135840,"confidence":1,"speaker":"A"},{"text":"of","start":3135840,"end":3136000,"confidence":1,"speaker":"A"},{"text":"the","start":3136000,"end":3136160,"confidence":1,"speaker":"A"},{"text":"client","start":3136160,"end":3136600,"confidence":0.99975586,"speaker":"A"},{"text":"is","start":3136920,"end":3137240,"confidence":0.99658203,"speaker":"A"},{"text":"being","start":3137240,"end":3137560,"confidence":0.9995117,"speaker":"A"},{"text":"able","start":3137560,"end":3137960,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3139960,"end":3140360,"confidence":1,"speaker":"A"},{"text":"say","start":3140440,"end":3140760,"confidence":0.9951172,"speaker":"A"},{"text":"what","start":3140760,"end":3140960,"confidence":0.9975586,"speaker":"A"},{"text":"transport","start":3140960,"end":3141520,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":3141520,"end":3141760,"confidence":0.99609375,"speaker":"A"},{"text":"use","start":3141760,"end":3142040,"confidence":0.9970703,"speaker":"A"},{"text":"in","start":3142360,"end":3142640,"confidence":0.9169922,"speaker":"A"},{"text":"Open","start":3142640,"end":3142840,"confidence":0.9995117,"speaker":"A"},{"text":"API.","start":3142840,"end":3143560,"confidence":0.7491455,"speaker":"A"},{"text":"And","start":3144760,"end":3145160,"confidence":0.9868164,"speaker":"A"},{"text":"there's","start":3148850,"end":3149330,"confidence":0.84521484,"speaker":"A"},{"text":"two","start":3149330,"end":3149650,"confidence":0.99609375,"speaker":"A"},{"text":"transports","start":3149970,"end":3150730,"confidence":0.9951172,"speaker":"A"},{"text":"available","start":3150730,"end":3151010,"confidence":0.9995117,"speaker":"A"},{"text":"right","start":3151010,"end":3151330,"confidence":0.9995117,"speaker":"A"},{"text":"now.","start":3151330,"end":3151650,"confidence":0.9970703,"speaker":"A"},{"text":"One","start":3152770,"end":3153170,"confidence":0.9663086,"speaker":"A"},{"text":"is,","start":3153330,"end":3153730,"confidence":0.9975586,"speaker":"A"},{"text":"one","start":3156850,"end":3157170,"confidence":0.9892578,"speaker":"A"},{"text":"is","start":3157170,"end":3157490,"confidence":0.99853516,"speaker":"A"},{"text":"your","start":3157490,"end":3157810,"confidence":0.99658203,"speaker":"A"},{"text":"regular","start":3157810,"end":3158210,"confidence":1,"speaker":"A"},{"text":"URL","start":3158210,"end":3158770,"confidence":0.9992676,"speaker":"A"},{"text":"session","start":3158770,"end":3159130,"confidence":0.99934894,"speaker":"A"},{"text":"for","start":3159130,"end":3159290,"confidence":0.99853516,"speaker":"A"},{"text":"clients,","start":3159290,"end":3159730,"confidence":0.78100586,"speaker":"A"},{"text":"which.","start":3159890,"end":3160210,"confidence":0.99853516,"speaker":"A"},{"text":"That","start":3160210,"end":3160410,"confidence":0.9916992,"speaker":"A"},{"text":"makes","start":3160410,"end":3160610,"confidence":0.9951172,"speaker":"A"},{"text":"sense.","start":3160610,"end":3160930,"confidence":0.9995117,"speaker":"A"}]},{"text":"Right. And then there's the Async HTTP client which is typically used like Swift NEO based for servers. The thing is that neither of those are available in wasp. Do you know what WASM is? I have no experience with it, but yes.","start":3160930,"end":3177810,"confidence":0.9897461,"words":[{"text":"Right.","start":3160930,"end":3161250,"confidence":0.9897461,"speaker":"A"},{"text":"And","start":3161570,"end":3161890,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":3161890,"end":3162089,"confidence":0.9892578,"speaker":"A"},{"text":"there's","start":3162089,"end":3162410,"confidence":0.9840495,"speaker":"A"},{"text":"the","start":3162410,"end":3162570,"confidence":0.9584961,"speaker":"A"},{"text":"Async","start":3162570,"end":3163170,"confidence":0.9949951,"speaker":"A"},{"text":"HTTP","start":3163170,"end":3163810,"confidence":0.9881592,"speaker":"A"},{"text":"client","start":3163810,"end":3164170,"confidence":0.9968262,"speaker":"A"},{"text":"which","start":3164170,"end":3164410,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3164410,"end":3164690,"confidence":0.9995117,"speaker":"A"},{"text":"typically","start":3164690,"end":3165090,"confidence":0.99975586,"speaker":"A"},{"text":"used","start":3165090,"end":3165410,"confidence":0.99658203,"speaker":"A"},{"text":"like","start":3165570,"end":3165850,"confidence":0.9838867,"speaker":"A"},{"text":"Swift","start":3165850,"end":3166130,"confidence":0.89575195,"speaker":"A"},{"text":"NEO","start":3166130,"end":3166530,"confidence":0.94506836,"speaker":"A"},{"text":"based","start":3166530,"end":3166850,"confidence":0.9980469,"speaker":"A"},{"text":"for","start":3167170,"end":3167490,"confidence":0.99560547,"speaker":"A"},{"text":"servers.","start":3167490,"end":3167970,"confidence":0.90649414,"speaker":"A"},{"text":"The","start":3169330,"end":3169610,"confidence":0.99853516,"speaker":"A"},{"text":"thing","start":3169610,"end":3169770,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3169770,"end":3169970,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":3169970,"end":3170170,"confidence":0.52441406,"speaker":"A"},{"text":"neither","start":3170170,"end":3170410,"confidence":0.99902344,"speaker":"A"},{"text":"of","start":3170410,"end":3170530,"confidence":0.9916992,"speaker":"A"},{"text":"those","start":3170530,"end":3170770,"confidence":0.9980469,"speaker":"A"},{"text":"are","start":3170930,"end":3171250,"confidence":0.99902344,"speaker":"A"},{"text":"available","start":3171250,"end":3171570,"confidence":0.99365234,"speaker":"A"},{"text":"in","start":3171730,"end":3172130,"confidence":0.9638672,"speaker":"A"},{"text":"wasp.","start":3172610,"end":3173170,"confidence":0.58813477,"speaker":"A"},{"text":"Do","start":3174290,"end":3174530,"confidence":0.6435547,"speaker":"A"},{"text":"you","start":3174530,"end":3174610,"confidence":0.99853516,"speaker":"A"},{"text":"know","start":3174610,"end":3174690,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":3174690,"end":3174810,"confidence":0.9980469,"speaker":"A"},{"text":"WASM","start":3174810,"end":3175210,"confidence":0.78027344,"speaker":"A"},{"text":"is?","start":3175210,"end":3175490,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3176050,"end":3176290,"confidence":0.99902344,"speaker":"C"},{"text":"have","start":3176290,"end":3176410,"confidence":0.9995117,"speaker":"C"},{"text":"no","start":3176410,"end":3176570,"confidence":1,"speaker":"C"},{"text":"experience","start":3176570,"end":3176850,"confidence":1,"speaker":"C"},{"text":"with","start":3176850,"end":3177130,"confidence":0.9995117,"speaker":"C"},{"text":"it,","start":3177130,"end":3177290,"confidence":0.99853516,"speaker":"C"},{"text":"but","start":3177290,"end":3177450,"confidence":0.8720703,"speaker":"C"},{"text":"yes.","start":3177450,"end":3177810,"confidence":0.9963379,"speaker":"C"}]},{"text":"Okay. It's. It's the web browser. Right. So.","start":3178850,"end":3182290,"confidence":0.9892578,"words":[{"text":"Okay.","start":3178850,"end":3179410,"confidence":0.9892578,"speaker":"A"},{"text":"It's.","start":3179490,"end":3179850,"confidence":0.96240234,"speaker":"A"},{"text":"It's","start":3179850,"end":3180290,"confidence":0.98811847,"speaker":"A"},{"text":"the","start":3180290,"end":3180570,"confidence":1,"speaker":"A"},{"text":"web","start":3180570,"end":3180810,"confidence":1,"speaker":"A"},{"text":"browser.","start":3180810,"end":3181210,"confidence":0.99869794,"speaker":"A"},{"text":"Right.","start":3181210,"end":3181490,"confidence":0.99853516,"speaker":"A"},{"text":"So.","start":3181890,"end":3182290,"confidence":0.98876953,"speaker":"A"}]},{"text":"So you really can't use Miskit in. In the. In WASM yet because there is no transport. Now having said that, why on earth would you use. Awesome.","start":3182690,"end":3193810,"confidence":0.9975586,"words":[{"text":"So","start":3182690,"end":3182970,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":3182970,"end":3183130,"confidence":1,"speaker":"A"},{"text":"really","start":3183130,"end":3183290,"confidence":1,"speaker":"A"},{"text":"can't","start":3183290,"end":3183490,"confidence":0.9998372,"speaker":"A"},{"text":"use","start":3183490,"end":3183690,"confidence":0.9995117,"speaker":"A"},{"text":"Miskit","start":3183690,"end":3184370,"confidence":0.95788574,"speaker":"A"},{"text":"in.","start":3184450,"end":3184850,"confidence":0.921875,"speaker":"A"},{"text":"In","start":3186450,"end":3186730,"confidence":0.99609375,"speaker":"A"},{"text":"the.","start":3186730,"end":3186930,"confidence":0.99609375,"speaker":"A"},{"text":"In","start":3186930,"end":3187170,"confidence":0.99658203,"speaker":"A"},{"text":"WASM","start":3187170,"end":3187690,"confidence":0.7368164,"speaker":"A"},{"text":"yet","start":3187690,"end":3187890,"confidence":0.85009766,"speaker":"A"},{"text":"because","start":3187890,"end":3188090,"confidence":1,"speaker":"A"},{"text":"there","start":3188090,"end":3188250,"confidence":1,"speaker":"A"},{"text":"is","start":3188250,"end":3188450,"confidence":0.9975586,"speaker":"A"},{"text":"no","start":3188450,"end":3188649,"confidence":0.9995117,"speaker":"A"},{"text":"transport.","start":3188649,"end":3189170,"confidence":0.998291,"speaker":"A"},{"text":"Now","start":3189170,"end":3189450,"confidence":0.9995117,"speaker":"A"},{"text":"having","start":3189450,"end":3189650,"confidence":1,"speaker":"A"},{"text":"said","start":3189650,"end":3189890,"confidence":1,"speaker":"A"},{"text":"that,","start":3189890,"end":3190210,"confidence":1,"speaker":"A"},{"text":"why","start":3190530,"end":3190850,"confidence":0.99902344,"speaker":"A"},{"text":"on","start":3190850,"end":3191050,"confidence":0.99902344,"speaker":"A"},{"text":"earth","start":3191050,"end":3191290,"confidence":1,"speaker":"A"},{"text":"would","start":3191290,"end":3191450,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":3191450,"end":3191730,"confidence":0.9995117,"speaker":"A"},{"text":"use.","start":3192050,"end":3192450,"confidence":0.99658203,"speaker":"A"},{"text":"Awesome.","start":3193090,"end":3193810,"confidence":0.7972819,"speaker":"A"}]},{"text":"Why would you use Miskit in the browser? Why not just use CloudKit js? So that's essentially, you know, What other questions do you have?","start":3194050,"end":3210940,"confidence":0.7753906,"words":[{"text":"Why","start":3194050,"end":3194330,"confidence":0.7753906,"speaker":"A"},{"text":"would","start":3194330,"end":3194450,"confidence":0.9667969,"speaker":"A"},{"text":"you","start":3194450,"end":3194530,"confidence":0.8652344,"speaker":"A"},{"text":"use","start":3194530,"end":3194650,"confidence":0.9169922,"speaker":"A"},{"text":"Miskit","start":3194650,"end":3195130,"confidence":0.9088135,"speaker":"A"},{"text":"in","start":3195130,"end":3195250,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":3195250,"end":3195330,"confidence":0.9995117,"speaker":"A"},{"text":"browser?","start":3195330,"end":3195690,"confidence":1,"speaker":"A"},{"text":"Why","start":3195690,"end":3195930,"confidence":0.9995117,"speaker":"A"},{"text":"not","start":3195930,"end":3196090,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":3196090,"end":3196250,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":3196250,"end":3196450,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":3196450,"end":3196970,"confidence":0.99780273,"speaker":"A"},{"text":"js?","start":3196970,"end":3197410,"confidence":0.8005371,"speaker":"A"},{"text":"So","start":3198380,"end":3198620,"confidence":0.98828125,"speaker":"A"},{"text":"that's","start":3199660,"end":3200100,"confidence":0.9996745,"speaker":"A"},{"text":"essentially,","start":3200100,"end":3200700,"confidence":0.9996338,"speaker":"A"},{"text":"you","start":3201580,"end":3201820,"confidence":0.765625,"speaker":"A"},{"text":"know,","start":3201820,"end":3202060,"confidence":0.77685547,"speaker":"A"},{"text":"What","start":3209260,"end":3209540,"confidence":0.99902344,"speaker":"A"},{"text":"other","start":3209540,"end":3209780,"confidence":0.9975586,"speaker":"A"},{"text":"questions","start":3209780,"end":3210340,"confidence":0.99975586,"speaker":"A"},{"text":"do","start":3210340,"end":3210500,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":3210500,"end":3210660,"confidence":1,"speaker":"A"},{"text":"have?","start":3210660,"end":3210940,"confidence":1,"speaker":"A"}]},{"text":"My brain is mushy right now, so.","start":3215660,"end":3218300,"confidence":0.96240234,"words":[{"text":"My","start":3215660,"end":3216060,"confidence":0.96240234,"speaker":"C"},{"text":"brain","start":3216300,"end":3216780,"confidence":0.99975586,"speaker":"C"},{"text":"is","start":3216780,"end":3217020,"confidence":0.9995117,"speaker":"C"},{"text":"mushy","start":3217020,"end":3217460,"confidence":0.9998372,"speaker":"C"},{"text":"right","start":3217460,"end":3217620,"confidence":0.9995117,"speaker":"C"},{"text":"now,","start":3217620,"end":3217900,"confidence":1,"speaker":"C"},{"text":"so.","start":3217900,"end":3218300,"confidence":0.9770508,"speaker":"C"}]},{"text":"Because of my presentation or because other. Things, I got two hours of sleep. Oh, I'm so sorry. So I'm following as best as I can.","start":3221020,"end":3231450,"confidence":0.9970703,"words":[{"text":"Because","start":3221020,"end":3221340,"confidence":0.9970703,"speaker":"A"},{"text":"of","start":3221340,"end":3221540,"confidence":0.99609375,"speaker":"A"},{"text":"my","start":3221540,"end":3221700,"confidence":0.99853516,"speaker":"A"},{"text":"presentation","start":3221700,"end":3222300,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":3222300,"end":3222540,"confidence":0.9902344,"speaker":"A"},{"text":"because","start":3222540,"end":3222860,"confidence":0.99853516,"speaker":"A"},{"text":"other.","start":3223020,"end":3223380,"confidence":0.99902344,"speaker":"A"},{"text":"Things,","start":3223380,"end":3223740,"confidence":0.9946289,"speaker":"C"},{"text":"I","start":3224570,"end":3224730,"confidence":0.98876953,"speaker":"C"},{"text":"got","start":3224730,"end":3224930,"confidence":0.9995117,"speaker":"C"},{"text":"two","start":3224930,"end":3225090,"confidence":0.9995117,"speaker":"C"},{"text":"hours","start":3225090,"end":3225290,"confidence":1,"speaker":"C"},{"text":"of","start":3225290,"end":3225450,"confidence":0.9873047,"speaker":"C"},{"text":"sleep.","start":3225450,"end":3225850,"confidence":0.9555664,"speaker":"C"},{"text":"Oh,","start":3226650,"end":3226970,"confidence":0.7734375,"speaker":"A"},{"text":"I'm","start":3226970,"end":3227130,"confidence":0.9970703,"speaker":"A"},{"text":"so","start":3227130,"end":3227290,"confidence":0.99365234,"speaker":"A"},{"text":"sorry.","start":3227290,"end":3227690,"confidence":0.9998372,"speaker":"A"},{"text":"So","start":3228170,"end":3228570,"confidence":0.95214844,"speaker":"C"},{"text":"I'm","start":3229770,"end":3230170,"confidence":0.97526044,"speaker":"C"},{"text":"following","start":3230170,"end":3230450,"confidence":0.99853516,"speaker":"C"},{"text":"as","start":3230450,"end":3230690,"confidence":0.9995117,"speaker":"C"},{"text":"best","start":3230690,"end":3230850,"confidence":0.9980469,"speaker":"C"},{"text":"as","start":3230850,"end":3231010,"confidence":0.9941406,"speaker":"C"},{"text":"I","start":3231010,"end":3231170,"confidence":0.9995117,"speaker":"C"},{"text":"can.","start":3231170,"end":3231450,"confidence":0.99902344,"speaker":"C"}]},{"text":"Snuggling. Yeah, the intro was basically how I originally built it for hard Twitch in 2020 for a private database login for the Apple Watch because I don't want to have a login screen. And so basically there's a way in the web browser to link your Apple Watch to your account and then from there you don't need to authenticate anymore. Nice. I built that all from hand and then in 23 they came out with the Open API generator which was like, oh wait, what if I can create an open API file out of Apple's 10 year old documentation?","start":3234330,"end":3270800,"confidence":0.87927246,"words":[{"text":"Snuggling.","start":3234330,"end":3235050,"confidence":0.87927246,"speaker":"A"},{"text":"Yeah,","start":3237050,"end":3237410,"confidence":0.96761066,"speaker":"A"},{"text":"the","start":3237410,"end":3237570,"confidence":0.99609375,"speaker":"A"},{"text":"intro","start":3237570,"end":3238010,"confidence":0.99975586,"speaker":"A"},{"text":"was","start":3238090,"end":3238410,"confidence":0.99853516,"speaker":"A"},{"text":"basically","start":3238410,"end":3238890,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":3239290,"end":3239610,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":3239610,"end":3239930,"confidence":0.9946289,"speaker":"A"},{"text":"originally","start":3240490,"end":3241010,"confidence":0.9998372,"speaker":"A"},{"text":"built","start":3241010,"end":3241250,"confidence":0.992513,"speaker":"A"},{"text":"it","start":3241250,"end":3241410,"confidence":0.9814453,"speaker":"A"},{"text":"for","start":3241410,"end":3241570,"confidence":0.9995117,"speaker":"A"},{"text":"hard","start":3241570,"end":3241730,"confidence":0.4362793,"speaker":"A"},{"text":"Twitch","start":3241730,"end":3242050,"confidence":0.9111328,"speaker":"A"},{"text":"in","start":3242050,"end":3242210,"confidence":0.99316406,"speaker":"A"},{"text":"2020","start":3242210,"end":3242810,"confidence":0.99854,"speaker":"A"},{"text":"for","start":3243210,"end":3243490,"confidence":0.94628906,"speaker":"A"},{"text":"a","start":3243490,"end":3243650,"confidence":0.7871094,"speaker":"A"},{"text":"private","start":3243650,"end":3243890,"confidence":1,"speaker":"A"},{"text":"database","start":3243890,"end":3244570,"confidence":0.99576825,"speaker":"A"},{"text":"login","start":3244730,"end":3245450,"confidence":0.9367676,"speaker":"A"},{"text":"for","start":3245930,"end":3246210,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":3246210,"end":3246370,"confidence":0.9980469,"speaker":"A"},{"text":"Apple","start":3246370,"end":3246650,"confidence":0.99975586,"speaker":"A"},{"text":"Watch","start":3246650,"end":3246890,"confidence":0.8803711,"speaker":"A"},{"text":"because","start":3246890,"end":3247170,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3247170,"end":3247290,"confidence":0.9975586,"speaker":"A"},{"text":"don't","start":3247290,"end":3247450,"confidence":0.99658203,"speaker":"A"},{"text":"want","start":3247450,"end":3247530,"confidence":0.8720703,"speaker":"A"},{"text":"to","start":3247530,"end":3247610,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":3247610,"end":3247690,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":3247690,"end":3247810,"confidence":0.99853516,"speaker":"A"},{"text":"login","start":3247810,"end":3248210,"confidence":0.99731445,"speaker":"A"},{"text":"screen.","start":3248210,"end":3248490,"confidence":0.99975586,"speaker":"A"},{"text":"And","start":3248490,"end":3248690,"confidence":0.98583984,"speaker":"A"},{"text":"so","start":3248690,"end":3248810,"confidence":0.99902344,"speaker":"A"},{"text":"basically","start":3248810,"end":3249210,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":3249210,"end":3249570,"confidence":0.99934894,"speaker":"A"},{"text":"a","start":3249570,"end":3249690,"confidence":0.99853516,"speaker":"A"},{"text":"way","start":3249690,"end":3249810,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":3249810,"end":3249930,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":3249930,"end":3250010,"confidence":0.99902344,"speaker":"A"},{"text":"web","start":3250010,"end":3250170,"confidence":0.9995117,"speaker":"A"},{"text":"browser","start":3250170,"end":3250450,"confidence":1,"speaker":"A"},{"text":"to","start":3250450,"end":3250610,"confidence":0.99902344,"speaker":"A"},{"text":"link","start":3250610,"end":3250810,"confidence":0.99975586,"speaker":"A"},{"text":"your","start":3250810,"end":3250970,"confidence":0.99902344,"speaker":"A"},{"text":"Apple","start":3250970,"end":3251290,"confidence":0.9333496,"speaker":"A"},{"text":"Watch","start":3251290,"end":3251610,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3251770,"end":3252050,"confidence":0.9975586,"speaker":"A"},{"text":"your","start":3252050,"end":3252210,"confidence":0.99902344,"speaker":"A"},{"text":"account","start":3252210,"end":3252490,"confidence":1,"speaker":"A"},{"text":"and","start":3252490,"end":3252770,"confidence":0.99316406,"speaker":"A"},{"text":"then","start":3252770,"end":3252970,"confidence":0.8930664,"speaker":"A"},{"text":"from","start":3252970,"end":3253130,"confidence":1,"speaker":"A"},{"text":"there","start":3253130,"end":3253290,"confidence":1,"speaker":"A"},{"text":"you","start":3253290,"end":3253450,"confidence":1,"speaker":"A"},{"text":"don't","start":3253450,"end":3253610,"confidence":1,"speaker":"A"},{"text":"need","start":3253610,"end":3253730,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3253730,"end":3253850,"confidence":0.95947266,"speaker":"A"},{"text":"authenticate","start":3253850,"end":3254370,"confidence":0.99975586,"speaker":"A"},{"text":"anymore.","start":3254370,"end":3254890,"confidence":0.991862,"speaker":"A"},{"text":"Nice.","start":3255280,"end":3255600,"confidence":0.94921875,"speaker":"A"},{"text":"I","start":3255760,"end":3256000,"confidence":0.9970703,"speaker":"A"},{"text":"built","start":3256000,"end":3256280,"confidence":0.8284505,"speaker":"A"},{"text":"that","start":3256280,"end":3256440,"confidence":0.9692383,"speaker":"A"},{"text":"all","start":3256440,"end":3256600,"confidence":0.99609375,"speaker":"A"},{"text":"from","start":3256600,"end":3256800,"confidence":1,"speaker":"A"},{"text":"hand","start":3256800,"end":3257120,"confidence":0.9951172,"speaker":"A"},{"text":"and","start":3258400,"end":3258680,"confidence":0.73095703,"speaker":"A"},{"text":"then","start":3258680,"end":3258960,"confidence":0.9941406,"speaker":"A"},{"text":"in","start":3259200,"end":3259520,"confidence":0.9970703,"speaker":"A"},{"text":"23","start":3259520,"end":3260040,"confidence":0.9939,"speaker":"A"},{"text":"they","start":3260040,"end":3260280,"confidence":0.9995117,"speaker":"A"},{"text":"came","start":3260280,"end":3260440,"confidence":0.9995117,"speaker":"A"},{"text":"out","start":3260440,"end":3260560,"confidence":0.94921875,"speaker":"A"},{"text":"with","start":3260560,"end":3260680,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3260680,"end":3260800,"confidence":0.93652344,"speaker":"A"},{"text":"Open","start":3260800,"end":3261000,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":3261000,"end":3261520,"confidence":0.9807129,"speaker":"A"},{"text":"generator","start":3261520,"end":3262160,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":3262640,"end":3263000,"confidence":0.99609375,"speaker":"A"},{"text":"was","start":3263000,"end":3263280,"confidence":0.64746094,"speaker":"A"},{"text":"like,","start":3263280,"end":3263480,"confidence":0.97558594,"speaker":"A"},{"text":"oh","start":3263480,"end":3263760,"confidence":0.91674805,"speaker":"A"},{"text":"wait,","start":3263760,"end":3264160,"confidence":0.9980469,"speaker":"A"},{"text":"what","start":3264160,"end":3264440,"confidence":0.99121094,"speaker":"A"},{"text":"if","start":3264440,"end":3264720,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3264800,"end":3265040,"confidence":0.9980469,"speaker":"A"},{"text":"can","start":3265040,"end":3265160,"confidence":0.99658203,"speaker":"A"},{"text":"create","start":3265160,"end":3265320,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":3265320,"end":3265480,"confidence":0.96777344,"speaker":"A"},{"text":"open","start":3265480,"end":3265720,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":3265720,"end":3266320,"confidence":0.98046875,"speaker":"A"},{"text":"file","start":3266800,"end":3267280,"confidence":0.98046875,"speaker":"A"},{"text":"out","start":3267520,"end":3267840,"confidence":0.99560547,"speaker":"A"},{"text":"of","start":3267840,"end":3268160,"confidence":0.99853516,"speaker":"A"},{"text":"Apple's","start":3268320,"end":3269040,"confidence":0.9937744,"speaker":"A"},{"text":"10","start":3269280,"end":3269600,"confidence":0.99951,"speaker":"A"},{"text":"year","start":3269600,"end":3269800,"confidence":0.9995117,"speaker":"A"},{"text":"old","start":3269800,"end":3270000,"confidence":0.99902344,"speaker":"A"},{"text":"documentation?","start":3270000,"end":3270800,"confidence":0.9923828,"speaker":"A"}]},{"text":"That'd be a lot of work, but I could do it. And I don't know if you heard, but there was this thing that came out a couple years ago called AI and it's really good at creating documentation for your code, but it's also really good at creating code for your documentation. And so I was like, oh yeah, this is great. Like I can just, I can just Feed it the documentation and go from there. And, like, basically, I've been going step by step through.","start":3273120,"end":3305140,"confidence":0.8873698,"words":[{"text":"That'd","start":3273120,"end":3273520,"confidence":0.8873698,"speaker":"A"},{"text":"be","start":3273520,"end":3273640,"confidence":1,"speaker":"A"},{"text":"a","start":3273640,"end":3273760,"confidence":0.99902344,"speaker":"A"},{"text":"lot","start":3273760,"end":3273840,"confidence":1,"speaker":"A"},{"text":"of","start":3273840,"end":3273960,"confidence":0.9975586,"speaker":"A"},{"text":"work,","start":3273960,"end":3274160,"confidence":1,"speaker":"A"},{"text":"but","start":3274160,"end":3274400,"confidence":0.6777344,"speaker":"A"},{"text":"I","start":3274400,"end":3274600,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":3274600,"end":3274760,"confidence":0.98876953,"speaker":"A"},{"text":"do","start":3274760,"end":3274920,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":3274920,"end":3275200,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":3275520,"end":3275920,"confidence":0.8173828,"speaker":"A"},{"text":"I","start":3276000,"end":3276280,"confidence":0.99902344,"speaker":"A"},{"text":"don't","start":3276280,"end":3276480,"confidence":0.9949544,"speaker":"A"},{"text":"know","start":3276480,"end":3276560,"confidence":0.99902344,"speaker":"A"},{"text":"if","start":3276560,"end":3276640,"confidence":1,"speaker":"A"},{"text":"you","start":3276640,"end":3276760,"confidence":0.9995117,"speaker":"A"},{"text":"heard,","start":3276760,"end":3277120,"confidence":0.99902344,"speaker":"A"},{"text":"but","start":3277600,"end":3278000,"confidence":0.9921875,"speaker":"A"},{"text":"there","start":3278960,"end":3279240,"confidence":0.9995117,"speaker":"A"},{"text":"was","start":3279240,"end":3279400,"confidence":0.9589844,"speaker":"A"},{"text":"this","start":3279400,"end":3279560,"confidence":0.9746094,"speaker":"A"},{"text":"thing","start":3279560,"end":3279720,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":3279720,"end":3279840,"confidence":0.99902344,"speaker":"A"},{"text":"came","start":3279840,"end":3279960,"confidence":0.99853516,"speaker":"A"},{"text":"out","start":3279960,"end":3280240,"confidence":0.9980469,"speaker":"A"},{"text":"a","start":3280240,"end":3280480,"confidence":0.99853516,"speaker":"A"},{"text":"couple","start":3280480,"end":3280720,"confidence":0.9992676,"speaker":"A"},{"text":"years","start":3280720,"end":3280920,"confidence":0.9995117,"speaker":"A"},{"text":"ago","start":3280920,"end":3281200,"confidence":0.9980469,"speaker":"A"},{"text":"called","start":3281780,"end":3282020,"confidence":0.99609375,"speaker":"A"},{"text":"AI","start":3282580,"end":3283220,"confidence":0.95092773,"speaker":"A"},{"text":"and","start":3283940,"end":3284340,"confidence":0.9873047,"speaker":"A"},{"text":"it's","start":3284980,"end":3285340,"confidence":0.9996745,"speaker":"A"},{"text":"really","start":3285340,"end":3285500,"confidence":0.9995117,"speaker":"A"},{"text":"good","start":3285500,"end":3285700,"confidence":0.9995117,"speaker":"A"},{"text":"at","start":3285700,"end":3285900,"confidence":0.98095703,"speaker":"A"},{"text":"creating","start":3285900,"end":3286260,"confidence":0.9995117,"speaker":"A"},{"text":"documentation","start":3286260,"end":3286940,"confidence":0.99990237,"speaker":"A"},{"text":"for","start":3286940,"end":3287180,"confidence":1,"speaker":"A"},{"text":"your","start":3287180,"end":3287340,"confidence":0.9995117,"speaker":"A"},{"text":"code,","start":3287340,"end":3287660,"confidence":0.94222003,"speaker":"A"},{"text":"but","start":3287660,"end":3287900,"confidence":0.9975586,"speaker":"A"},{"text":"it's","start":3287900,"end":3288100,"confidence":0.9998372,"speaker":"A"},{"text":"also","start":3288100,"end":3288260,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":3288260,"end":3288500,"confidence":0.5620117,"speaker":"A"},{"text":"good","start":3288500,"end":3288700,"confidence":0.9995117,"speaker":"A"},{"text":"at","start":3288700,"end":3288860,"confidence":0.9995117,"speaker":"A"},{"text":"creating","start":3288860,"end":3289140,"confidence":0.96777344,"speaker":"A"},{"text":"code","start":3289140,"end":3289420,"confidence":0.9996745,"speaker":"A"},{"text":"for","start":3289420,"end":3289620,"confidence":0.9995117,"speaker":"A"},{"text":"your","start":3289620,"end":3289820,"confidence":0.9995117,"speaker":"A"},{"text":"documentation.","start":3289820,"end":3290500,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":3291300,"end":3291580,"confidence":0.8925781,"speaker":"A"},{"text":"so","start":3291580,"end":3291700,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":3291700,"end":3291820,"confidence":0.9975586,"speaker":"A"},{"text":"was","start":3291820,"end":3292020,"confidence":0.9995117,"speaker":"A"},{"text":"like,","start":3292020,"end":3292340,"confidence":0.99658203,"speaker":"A"},{"text":"oh","start":3292500,"end":3292980,"confidence":0.9580078,"speaker":"A"},{"text":"yeah,","start":3293460,"end":3293940,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":3293940,"end":3294220,"confidence":0.9951172,"speaker":"A"},{"text":"is","start":3294220,"end":3294380,"confidence":0.99853516,"speaker":"A"},{"text":"great.","start":3294380,"end":3294660,"confidence":0.9980469,"speaker":"A"},{"text":"Like","start":3295060,"end":3295460,"confidence":0.9238281,"speaker":"A"},{"text":"I","start":3295460,"end":3295740,"confidence":0.9707031,"speaker":"A"},{"text":"can","start":3295740,"end":3295900,"confidence":0.99658203,"speaker":"A"},{"text":"just,","start":3295900,"end":3296180,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3296740,"end":3296980,"confidence":0.97753906,"speaker":"A"},{"text":"can","start":3296980,"end":3297140,"confidence":0.7270508,"speaker":"A"},{"text":"just","start":3297140,"end":3297420,"confidence":0.9995117,"speaker":"A"},{"text":"Feed","start":3297420,"end":3297739,"confidence":0.9968262,"speaker":"A"},{"text":"it","start":3297739,"end":3297900,"confidence":0.8671875,"speaker":"A"},{"text":"the","start":3297900,"end":3298060,"confidence":0.99853516,"speaker":"A"},{"text":"documentation","start":3298060,"end":3298740,"confidence":0.99921876,"speaker":"A"},{"text":"and","start":3298980,"end":3299380,"confidence":0.9238281,"speaker":"A"},{"text":"go","start":3301140,"end":3301420,"confidence":0.9970703,"speaker":"A"},{"text":"from","start":3301420,"end":3301620,"confidence":0.9995117,"speaker":"A"},{"text":"there.","start":3301620,"end":3301940,"confidence":0.9995117,"speaker":"A"},{"text":"And,","start":3302020,"end":3302340,"confidence":0.97998047,"speaker":"A"},{"text":"like,","start":3302340,"end":3302660,"confidence":0.9477539,"speaker":"A"},{"text":"basically,","start":3302820,"end":3303300,"confidence":0.99975586,"speaker":"A"},{"text":"I've","start":3303300,"end":3303540,"confidence":0.99072266,"speaker":"A"},{"text":"been","start":3303540,"end":3303660,"confidence":0.9902344,"speaker":"A"},{"text":"going","start":3303660,"end":3303860,"confidence":0.9995117,"speaker":"A"},{"text":"step","start":3303860,"end":3304060,"confidence":0.9995117,"speaker":"A"},{"text":"by","start":3304060,"end":3304260,"confidence":1,"speaker":"A"},{"text":"step","start":3304260,"end":3304580,"confidence":1,"speaker":"A"},{"text":"through.","start":3304740,"end":3305140,"confidence":0.98876953,"speaker":"A"}]},{"text":"Like I said, if you looked at the miskit repo, like, I'm going through step by step and adding new APIs based on what's available in the documentation, piece by piece. And I would say at this point, it's like most of the really, like 80% of that people use is there. There's like, stuff like subscriptions and zones that I'm still trying to figure out, but it's. It's pretty close to done at this point. Mm.","start":3305940,"end":3331900,"confidence":0.9980469,"words":[{"text":"Like","start":3305940,"end":3306260,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3306260,"end":3306460,"confidence":1,"speaker":"A"},{"text":"said,","start":3306460,"end":3306620,"confidence":1,"speaker":"A"},{"text":"if","start":3306620,"end":3306820,"confidence":0.6225586,"speaker":"A"},{"text":"you","start":3306820,"end":3306980,"confidence":1,"speaker":"A"},{"text":"looked","start":3306980,"end":3307220,"confidence":0.9802246,"speaker":"A"},{"text":"at","start":3307220,"end":3307340,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3307340,"end":3307620,"confidence":0.94140625,"speaker":"A"},{"text":"miskit","start":3307700,"end":3308500,"confidence":0.876709,"speaker":"A"},{"text":"repo,","start":3308780,"end":3309300,"confidence":0.99072266,"speaker":"A"},{"text":"like,","start":3309300,"end":3309580,"confidence":0.9838867,"speaker":"A"},{"text":"I'm","start":3309580,"end":3309820,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":3309820,"end":3309940,"confidence":0.9995117,"speaker":"A"},{"text":"through","start":3309940,"end":3310140,"confidence":0.9995117,"speaker":"A"},{"text":"step","start":3310140,"end":3310340,"confidence":0.9946289,"speaker":"A"},{"text":"by","start":3310340,"end":3310500,"confidence":0.99902344,"speaker":"A"},{"text":"step","start":3310500,"end":3310660,"confidence":1,"speaker":"A"},{"text":"and","start":3310660,"end":3310820,"confidence":0.93896484,"speaker":"A"},{"text":"adding","start":3310820,"end":3311260,"confidence":0.998291,"speaker":"A"},{"text":"new","start":3311660,"end":3312060,"confidence":0.9995117,"speaker":"A"},{"text":"APIs","start":3312380,"end":3313100,"confidence":0.98168945,"speaker":"A"},{"text":"based","start":3314300,"end":3314620,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":3314620,"end":3314780,"confidence":0.9995117,"speaker":"A"},{"text":"what's","start":3314780,"end":3315020,"confidence":0.9996745,"speaker":"A"},{"text":"available","start":3315020,"end":3315220,"confidence":1,"speaker":"A"},{"text":"in","start":3315220,"end":3315460,"confidence":0.95654297,"speaker":"A"},{"text":"the","start":3315460,"end":3315580,"confidence":0.99902344,"speaker":"A"},{"text":"documentation,","start":3315580,"end":3316300,"confidence":0.99677736,"speaker":"A"},{"text":"piece","start":3316700,"end":3317060,"confidence":0.9938151,"speaker":"A"},{"text":"by","start":3317060,"end":3317220,"confidence":0.9291992,"speaker":"A"},{"text":"piece.","start":3317220,"end":3317500,"confidence":0.99332684,"speaker":"A"},{"text":"And","start":3317500,"end":3317660,"confidence":0.99121094,"speaker":"A"},{"text":"I","start":3317660,"end":3317740,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":3317740,"end":3317820,"confidence":1,"speaker":"A"},{"text":"say","start":3317820,"end":3317940,"confidence":1,"speaker":"A"},{"text":"at","start":3317940,"end":3318060,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":3318060,"end":3318180,"confidence":1,"speaker":"A"},{"text":"point,","start":3318180,"end":3318340,"confidence":0.99902344,"speaker":"A"},{"text":"it's","start":3318340,"end":3318580,"confidence":0.9899089,"speaker":"A"},{"text":"like","start":3318580,"end":3318860,"confidence":0.9975586,"speaker":"A"},{"text":"most","start":3319340,"end":3319660,"confidence":1,"speaker":"A"},{"text":"of","start":3319660,"end":3319820,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":3319820,"end":3320020,"confidence":0.99658203,"speaker":"A"},{"text":"really,","start":3320020,"end":3320380,"confidence":0.99658203,"speaker":"A"},{"text":"like","start":3320620,"end":3320940,"confidence":0.98876953,"speaker":"A"},{"text":"80%","start":3320940,"end":3321500,"confidence":0.96655,"speaker":"A"},{"text":"of","start":3321500,"end":3321780,"confidence":0.7285156,"speaker":"A"},{"text":"that","start":3321780,"end":3321940,"confidence":0.9941406,"speaker":"A"},{"text":"people","start":3321940,"end":3322140,"confidence":1,"speaker":"A"},{"text":"use","start":3322140,"end":3322420,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3322420,"end":3322660,"confidence":0.98876953,"speaker":"A"},{"text":"there.","start":3322660,"end":3322940,"confidence":0.9951172,"speaker":"A"},{"text":"There's","start":3322940,"end":3323340,"confidence":0.9998372,"speaker":"A"},{"text":"like,","start":3323340,"end":3323500,"confidence":0.99121094,"speaker":"A"},{"text":"stuff","start":3323500,"end":3323780,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":3323780,"end":3323980,"confidence":0.99902344,"speaker":"A"},{"text":"subscriptions","start":3323980,"end":3324619,"confidence":0.99501956,"speaker":"A"},{"text":"and","start":3324619,"end":3324940,"confidence":0.99658203,"speaker":"A"},{"text":"zones","start":3324940,"end":3325300,"confidence":0.95703125,"speaker":"A"},{"text":"that","start":3325300,"end":3325660,"confidence":0.99316406,"speaker":"A"},{"text":"I'm","start":3325980,"end":3326340,"confidence":0.9868164,"speaker":"A"},{"text":"still","start":3326340,"end":3326500,"confidence":0.9975586,"speaker":"A"},{"text":"trying","start":3326500,"end":3326700,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3326700,"end":3326860,"confidence":0.9995117,"speaker":"A"},{"text":"figure","start":3326860,"end":3327140,"confidence":0.99975586,"speaker":"A"},{"text":"out,","start":3327140,"end":3327420,"confidence":0.99121094,"speaker":"A"},{"text":"but","start":3328460,"end":3328780,"confidence":0.9941406,"speaker":"A"},{"text":"it's.","start":3328780,"end":3329100,"confidence":0.9900716,"speaker":"A"},{"text":"It's","start":3329100,"end":3329340,"confidence":0.98746747,"speaker":"A"},{"text":"pretty","start":3329340,"end":3329540,"confidence":0.9991862,"speaker":"A"},{"text":"close","start":3329540,"end":3329740,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3329740,"end":3329980,"confidence":0.9975586,"speaker":"A"},{"text":"done","start":3329980,"end":3330260,"confidence":0.95410156,"speaker":"A"},{"text":"at","start":3330260,"end":3330460,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":3330460,"end":3330620,"confidence":0.95751953,"speaker":"A"},{"text":"point.","start":3330620,"end":3330940,"confidence":0.66552734,"speaker":"A"},{"text":"Mm.","start":3331260,"end":3331900,"confidence":0.62402344,"speaker":"B"}]},{"text":"If you use it. Yeah, it's one of those. Because I. Go ahead. Yeah.","start":3335110,"end":3338950,"confidence":0.56103516,"words":[{"text":"If","start":3335110,"end":3335230,"confidence":0.56103516,"speaker":"A"},{"text":"you","start":3335230,"end":3335350,"confidence":0.99902344,"speaker":"A"},{"text":"use","start":3335350,"end":3335510,"confidence":0.9975586,"speaker":"A"},{"text":"it.","start":3335510,"end":3335830,"confidence":0.5029297,"speaker":"A"},{"text":"Yeah,","start":3336230,"end":3336550,"confidence":0.9943034,"speaker":"C"},{"text":"it's","start":3336550,"end":3336630,"confidence":0.94905597,"speaker":"C"},{"text":"one","start":3336630,"end":3336750,"confidence":0.9902344,"speaker":"C"},{"text":"of","start":3336750,"end":3336870,"confidence":0.99853516,"speaker":"C"},{"text":"those.","start":3336870,"end":3337110,"confidence":0.9760742,"speaker":"C"},{"text":"Because","start":3337270,"end":3337630,"confidence":0.7348633,"speaker":"A"},{"text":"I.","start":3337630,"end":3337990,"confidence":0.86621094,"speaker":"A"},{"text":"Go","start":3338070,"end":3338350,"confidence":0.9902344,"speaker":"A"},{"text":"ahead.","start":3338350,"end":3338590,"confidence":0.9980469,"speaker":"A"},{"text":"Yeah.","start":3338590,"end":3338950,"confidence":0.99397784,"speaker":"C"}]},{"text":"I was gonna say it's one of those projects that makes me want to set up a. Like a vapor server or something just to do some Swift on the server. Yeah. Or just like, I wonder if there's like, something you do on a pie, like just hook it up to a CloudKit database. Like, there's a lot you could do here because all you need is decent os.","start":3338950,"end":3357510,"confidence":0.49267578,"words":[{"text":"I","start":3338950,"end":3339110,"confidence":0.49267578,"speaker":"C"},{"text":"was","start":3339110,"end":3339230,"confidence":0.9189453,"speaker":"C"},{"text":"gonna","start":3339230,"end":3339430,"confidence":0.83776855,"speaker":"C"},{"text":"say","start":3339430,"end":3339510,"confidence":1,"speaker":"C"},{"text":"it's","start":3339510,"end":3339670,"confidence":0.9998372,"speaker":"C"},{"text":"one","start":3339670,"end":3339750,"confidence":1,"speaker":"C"},{"text":"of","start":3339750,"end":3339830,"confidence":0.9995117,"speaker":"C"},{"text":"those","start":3339830,"end":3339950,"confidence":0.9995117,"speaker":"C"},{"text":"projects","start":3339950,"end":3340310,"confidence":0.99975586,"speaker":"C"},{"text":"that","start":3340310,"end":3340430,"confidence":1,"speaker":"C"},{"text":"makes","start":3340430,"end":3340590,"confidence":0.9995117,"speaker":"C"},{"text":"me","start":3340590,"end":3340750,"confidence":0.9995117,"speaker":"C"},{"text":"want","start":3340750,"end":3340910,"confidence":0.9604492,"speaker":"C"},{"text":"to","start":3340910,"end":3341070,"confidence":1,"speaker":"C"},{"text":"set","start":3341070,"end":3341230,"confidence":1,"speaker":"C"},{"text":"up","start":3341230,"end":3341390,"confidence":0.9995117,"speaker":"C"},{"text":"a.","start":3341390,"end":3341670,"confidence":0.96240234,"speaker":"C"},{"text":"Like","start":3342150,"end":3342470,"confidence":0.9941406,"speaker":"C"},{"text":"a","start":3342470,"end":3342750,"confidence":0.99902344,"speaker":"C"},{"text":"vapor","start":3342750,"end":3343310,"confidence":0.98551434,"speaker":"C"},{"text":"server","start":3343310,"end":3343630,"confidence":0.9995117,"speaker":"C"},{"text":"or","start":3343630,"end":3343790,"confidence":0.99853516,"speaker":"C"},{"text":"something","start":3343790,"end":3344030,"confidence":1,"speaker":"C"},{"text":"just","start":3344030,"end":3344270,"confidence":1,"speaker":"C"},{"text":"to","start":3344270,"end":3344390,"confidence":1,"speaker":"C"},{"text":"do","start":3344390,"end":3344510,"confidence":0.9995117,"speaker":"C"},{"text":"some","start":3344510,"end":3344670,"confidence":1,"speaker":"C"},{"text":"Swift","start":3344670,"end":3344990,"confidence":0.99975586,"speaker":"C"},{"text":"on","start":3344990,"end":3345110,"confidence":1,"speaker":"C"},{"text":"the","start":3345110,"end":3345230,"confidence":1,"speaker":"C"},{"text":"server.","start":3345230,"end":3345670,"confidence":0.99975586,"speaker":"C"},{"text":"Yeah.","start":3346630,"end":3347110,"confidence":0.9916992,"speaker":"A"},{"text":"Or","start":3347270,"end":3347590,"confidence":0.92041016,"speaker":"A"},{"text":"just","start":3347590,"end":3347830,"confidence":0.99902344,"speaker":"A"},{"text":"like,","start":3347830,"end":3348150,"confidence":0.99658203,"speaker":"A"},{"text":"I","start":3348870,"end":3349150,"confidence":0.9760742,"speaker":"A"},{"text":"wonder","start":3349150,"end":3349390,"confidence":0.9980469,"speaker":"A"},{"text":"if","start":3349390,"end":3349510,"confidence":0.6303711,"speaker":"A"},{"text":"there's","start":3349510,"end":3349710,"confidence":0.867513,"speaker":"A"},{"text":"like,","start":3349710,"end":3349830,"confidence":0.9819336,"speaker":"A"},{"text":"something","start":3349830,"end":3349990,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":3349990,"end":3350189,"confidence":0.9926758,"speaker":"A"},{"text":"do","start":3350189,"end":3350309,"confidence":0.99853516,"speaker":"A"},{"text":"on","start":3350309,"end":3350430,"confidence":0.9970703,"speaker":"A"},{"text":"a","start":3350430,"end":3350590,"confidence":0.9946289,"speaker":"A"},{"text":"pie,","start":3350590,"end":3350950,"confidence":0.7319336,"speaker":"A"},{"text":"like","start":3351750,"end":3352150,"confidence":0.97265625,"speaker":"A"},{"text":"just","start":3352230,"end":3352470,"confidence":0.99853516,"speaker":"A"},{"text":"hook","start":3352470,"end":3352630,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":3352630,"end":3352750,"confidence":0.99853516,"speaker":"A"},{"text":"up","start":3352750,"end":3352870,"confidence":1,"speaker":"A"},{"text":"to","start":3352870,"end":3352990,"confidence":1,"speaker":"A"},{"text":"a","start":3352990,"end":3353110,"confidence":0.9946289,"speaker":"A"},{"text":"CloudKit","start":3353110,"end":3353550,"confidence":0.9953613,"speaker":"A"},{"text":"database.","start":3353550,"end":3353990,"confidence":1,"speaker":"A"},{"text":"Like,","start":3353990,"end":3354190,"confidence":0.99121094,"speaker":"A"},{"text":"there's","start":3354190,"end":3354430,"confidence":0.9998372,"speaker":"A"},{"text":"a","start":3354430,"end":3354550,"confidence":1,"speaker":"A"},{"text":"lot","start":3354550,"end":3354710,"confidence":1,"speaker":"A"},{"text":"you","start":3354710,"end":3354870,"confidence":1,"speaker":"A"},{"text":"could","start":3354870,"end":3354990,"confidence":0.98828125,"speaker":"A"},{"text":"do","start":3354990,"end":3355150,"confidence":1,"speaker":"A"},{"text":"here","start":3355150,"end":3355350,"confidence":1,"speaker":"A"},{"text":"because","start":3355350,"end":3355550,"confidence":0.8598633,"speaker":"A"},{"text":"all","start":3355550,"end":3355710,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":3355710,"end":3355870,"confidence":1,"speaker":"A"},{"text":"need","start":3355870,"end":3356030,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":3356030,"end":3356310,"confidence":0.97314453,"speaker":"A"},{"text":"decent","start":3356710,"end":3357150,"confidence":0.9091797,"speaker":"A"},{"text":"os.","start":3357150,"end":3357510,"confidence":0.95581055,"speaker":"A"}]},{"text":"I don't know anything about sharing. I haven't done anything with sharing yet, so I still have to do that and a few other things, but. No, yeah,. It's an interesting idea. Thank you.","start":3358950,"end":3370460,"confidence":0.9995117,"words":[{"text":"I","start":3358950,"end":3359230,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":3359230,"end":3359430,"confidence":0.9998372,"speaker":"A"},{"text":"know","start":3359430,"end":3359550,"confidence":0.9995117,"speaker":"A"},{"text":"anything","start":3359550,"end":3359870,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":3359870,"end":3360030,"confidence":0.9995117,"speaker":"A"},{"text":"sharing.","start":3360030,"end":3360430,"confidence":0.9663086,"speaker":"A"},{"text":"I","start":3360430,"end":3360670,"confidence":1,"speaker":"A"},{"text":"haven't","start":3360670,"end":3360870,"confidence":0.9992676,"speaker":"A"},{"text":"done","start":3360870,"end":3360990,"confidence":0.9995117,"speaker":"A"},{"text":"anything","start":3360990,"end":3361310,"confidence":0.99975586,"speaker":"A"},{"text":"with","start":3361310,"end":3361470,"confidence":0.8676758,"speaker":"A"},{"text":"sharing","start":3361470,"end":3361830,"confidence":0.99731445,"speaker":"A"},{"text":"yet,","start":3361830,"end":3362110,"confidence":0.98779297,"speaker":"A"},{"text":"so","start":3362110,"end":3362310,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":3362310,"end":3362430,"confidence":0.9663086,"speaker":"A"},{"text":"still","start":3362430,"end":3362590,"confidence":0.9589844,"speaker":"A"},{"text":"have","start":3362590,"end":3362750,"confidence":0.77441406,"speaker":"A"},{"text":"to","start":3362750,"end":3362870,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":3362870,"end":3362990,"confidence":0.9951172,"speaker":"A"},{"text":"that","start":3362990,"end":3363190,"confidence":1,"speaker":"A"},{"text":"and","start":3363190,"end":3363390,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":3363390,"end":3363510,"confidence":0.9995117,"speaker":"A"},{"text":"few","start":3363510,"end":3363630,"confidence":1,"speaker":"A"},{"text":"other","start":3363630,"end":3363830,"confidence":0.99902344,"speaker":"A"},{"text":"things,","start":3363830,"end":3364070,"confidence":0.9995117,"speaker":"A"},{"text":"but.","start":3364070,"end":3364390,"confidence":0.98876953,"speaker":"A"},{"text":"No,","start":3364940,"end":3365180,"confidence":0.6020508,"speaker":"A"},{"text":"yeah,.","start":3365180,"end":3365740,"confidence":0.9869792,"speaker":"A"},{"text":"It's","start":3367740,"end":3368060,"confidence":0.97021484,"speaker":"C"},{"text":"an","start":3368060,"end":3368180,"confidence":0.99609375,"speaker":"C"},{"text":"interesting","start":3368180,"end":3368500,"confidence":0.99975586,"speaker":"C"},{"text":"idea.","start":3368500,"end":3368940,"confidence":0.98706055,"speaker":"C"},{"text":"Thank","start":3369900,"end":3370220,"confidence":0.9868164,"speaker":"A"},{"text":"you.","start":3370220,"end":3370460,"confidence":0.9975586,"speaker":"A"}]},{"text":"Yeah. Well, thank you for joining, Josh. Yeah. Thanks for hosting this and sharing this info. It's nice.","start":3371420,"end":3377340,"confidence":0.88997394,"words":[{"text":"Yeah.","start":3371420,"end":3371900,"confidence":0.88997394,"speaker":"B"},{"text":"Well,","start":3371900,"end":3372100,"confidence":0.9980469,"speaker":"A"},{"text":"thank","start":3372100,"end":3372300,"confidence":1,"speaker":"A"},{"text":"you","start":3372300,"end":3372420,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":3372420,"end":3372580,"confidence":0.99902344,"speaker":"A"},{"text":"joining,","start":3372580,"end":3372860,"confidence":0.96809894,"speaker":"A"},{"text":"Josh.","start":3372860,"end":3373260,"confidence":0.98461914,"speaker":"A"},{"text":"Yeah.","start":3373660,"end":3374060,"confidence":0.81844074,"speaker":"C"},{"text":"Thanks","start":3374060,"end":3374300,"confidence":1,"speaker":"C"},{"text":"for","start":3374300,"end":3374460,"confidence":0.9995117,"speaker":"C"},{"text":"hosting","start":3374460,"end":3374820,"confidence":0.9995117,"speaker":"C"},{"text":"this","start":3374820,"end":3375020,"confidence":0.9707031,"speaker":"C"},{"text":"and","start":3375020,"end":3375340,"confidence":0.99902344,"speaker":"C"},{"text":"sharing","start":3375900,"end":3376340,"confidence":0.9934082,"speaker":"C"},{"text":"this","start":3376340,"end":3376500,"confidence":0.9995117,"speaker":"C"},{"text":"info.","start":3376500,"end":3376820,"confidence":0.9995117,"speaker":"C"},{"text":"It's","start":3376820,"end":3377020,"confidence":0.9941406,"speaker":"C"},{"text":"nice.","start":3377020,"end":3377340,"confidence":1,"speaker":"C"}]},{"text":"Yeah. If you ever run into anything, let me know. Will do. All right, talk to you later. All right, sounds good.","start":3378060,"end":3385180,"confidence":0.9866536,"words":[{"text":"Yeah.","start":3378060,"end":3378540,"confidence":0.9866536,"speaker":"A"},{"text":"If","start":3378620,"end":3378980,"confidence":0.9794922,"speaker":"A"},{"text":"you","start":3378980,"end":3379260,"confidence":0.9995117,"speaker":"A"},{"text":"ever","start":3379260,"end":3379500,"confidence":1,"speaker":"A"},{"text":"run","start":3379500,"end":3379700,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":3379700,"end":3379860,"confidence":1,"speaker":"A"},{"text":"anything,","start":3379860,"end":3380180,"confidence":1,"speaker":"A"},{"text":"let","start":3380180,"end":3380300,"confidence":1,"speaker":"A"},{"text":"me","start":3380300,"end":3380459,"confidence":1,"speaker":"A"},{"text":"know.","start":3380459,"end":3380780,"confidence":0.9995117,"speaker":"A"},{"text":"Will","start":3381420,"end":3381740,"confidence":0.5800781,"speaker":"A"},{"text":"do.","start":3381740,"end":3382060,"confidence":0.99365234,"speaker":"A"},{"text":"All","start":3382940,"end":3383220,"confidence":0.9814453,"speaker":"A"},{"text":"right,","start":3383220,"end":3383500,"confidence":1,"speaker":"A"},{"text":"talk","start":3383660,"end":3383940,"confidence":1,"speaker":"A"},{"text":"to","start":3383940,"end":3384100,"confidence":1,"speaker":"A"},{"text":"you","start":3384100,"end":3384220,"confidence":0.9995117,"speaker":"A"},{"text":"later.","start":3384220,"end":3384420,"confidence":1,"speaker":"A"},{"text":"All","start":3384420,"end":3384620,"confidence":0.9223633,"speaker":"A"},{"text":"right,","start":3384620,"end":3384780,"confidence":0.9145508,"speaker":"A"},{"text":"sounds","start":3384780,"end":3385020,"confidence":1,"speaker":"A"},{"text":"good.","start":3385020,"end":3385180,"confidence":1,"speaker":"A"}]},{"text":"See you. Bye. Bye.","start":3385180,"end":3387340,"confidence":0.9975586,"words":[{"text":"See","start":3385180,"end":3385380,"confidence":0.9975586,"speaker":"C"},{"text":"you.","start":3385380,"end":3385660,"confidence":0.54296875,"speaker":"C"},{"text":"Bye.","start":3386220,"end":3386700,"confidence":0.9375,"speaker":"A"},{"text":"Bye.","start":3386860,"end":3387340,"confidence":0.9519043,"speaker":"C"}]}],"id":"8a542ac0-f58a-4b02-b801-9926da98bdd0","confidence":0.97097707,"audio_duration":3388} \ No newline at end of file diff --git a/docs/transcriptions/timestamps.json b/docs/transcriptions/timestamps.json new file mode 100644 index 00000000..f31f7d33 --- /dev/null +++ b/docs/transcriptions/timestamps.json @@ -0,0 +1 @@ +[{"text":"Hey,","start":262980,"end":263180,"confidence":0.99658203,"speaker":"A"},{"text":"Evan,","start":263180,"end":263580,"confidence":0.99609375,"speaker":"A"},{"text":"can","start":263580,"end":263700,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":263700,"end":263780,"confidence":0.99316406,"speaker":"A"},{"text":"hear","start":263780,"end":263900,"confidence":1,"speaker":"A"},{"text":"me","start":263900,"end":264020,"confidence":1,"speaker":"A"},{"text":"all","start":264020,"end":264140,"confidence":0.87158203,"speaker":"A"},{"text":"right?","start":264140,"end":264420,"confidence":0.96240234,"speaker":"A"},{"text":"Yeah,","start":264660,"end":265020,"confidence":0.9741211,"speaker":"B"},{"text":"I","start":265020,"end":265140,"confidence":1,"speaker":"B"},{"text":"can","start":265140,"end":265260,"confidence":1,"speaker":"B"},{"text":"hear","start":265260,"end":265420,"confidence":1,"speaker":"B"},{"text":"you.","start":265420,"end":265700,"confidence":0.99365234,"speaker":"B"},{"text":"Awesome.","start":266420,"end":267060,"confidence":0.9998372,"speaker":"A"},{"text":"How","start":267060,"end":267340,"confidence":1,"speaker":"A"},{"text":"do","start":267340,"end":267500,"confidence":1,"speaker":"A"},{"text":"I","start":267500,"end":267660,"confidence":1,"speaker":"A"},{"text":"sound?","start":267660,"end":268020,"confidence":0.99975586,"speaker":"A"},{"text":"Good.","start":268340,"end":268740,"confidence":0.99902344,"speaker":"A"},{"text":"I've","start":270260,"end":270740,"confidence":0.7714844,"speaker":"A"},{"text":"used","start":270740,"end":270940,"confidence":0.99316406,"speaker":"A"},{"text":"this","start":270940,"end":271140,"confidence":0.9736328,"speaker":"A"},{"text":"microphone","start":271140,"end":271660,"confidence":0.9484375,"speaker":"A"},{"text":"in","start":271660,"end":271820,"confidence":0.9946289,"speaker":"A"},{"text":"ages.","start":271820,"end":272340,"confidence":0.9995117,"speaker":"A"},{"text":"It's","start":273060,"end":273420,"confidence":0.99397784,"speaker":"A"},{"text":"like","start":273420,"end":273580,"confidence":0.99121094,"speaker":"A"},{"text":"all","start":273580,"end":273780,"confidence":0.98583984,"speaker":"A"},{"text":"dusty.","start":273780,"end":274420,"confidence":0.99934894,"speaker":"A"},{"text":"How","start":281140,"end":281500,"confidence":0.6699219,"speaker":"A"},{"text":"you","start":281500,"end":281700,"confidence":0.97021484,"speaker":"A"},{"text":"think","start":281700,"end":281820,"confidence":1,"speaker":"A"},{"text":"I","start":281820,"end":281940,"confidence":0.99853516,"speaker":"A"},{"text":"should","start":281940,"end":282060,"confidence":0.9995117,"speaker":"A"},{"text":"wait","start":282060,"end":282260,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":282260,"end":282380,"confidence":0.99316406,"speaker":"A"},{"text":"five","start":282380,"end":282540,"confidence":0.9995117,"speaker":"A"},{"text":"minutes","start":282540,"end":282820,"confidence":1,"speaker":"A"},{"text":"for","start":282820,"end":283020,"confidence":0.9995117,"speaker":"A"},{"text":"people","start":283020,"end":283220,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":283220,"end":283380,"confidence":0.9916992,"speaker":"A"},{"text":"come","start":283380,"end":283540,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":283540,"end":283780,"confidence":0.99902344,"speaker":"A"},{"text":"or.","start":283780,"end":284100,"confidence":0.9394531,"speaker":"A"},{"text":"Probably.","start":284260,"end":284740,"confidence":0.8670247,"speaker":"B"},{"text":"Yeah,","start":284980,"end":285460,"confidence":0.99316406,"speaker":"B"},{"text":"that","start":285770,"end":285970,"confidence":0.72314453,"speaker":"B"},{"text":"there's","start":285970,"end":286410,"confidence":0.8248698,"speaker":"B"},{"text":"if.","start":286490,"end":286890,"confidence":0.97558594,"speaker":"B"},{"text":"Yeah,","start":286970,"end":287530,"confidence":0.99869794,"speaker":"B"},{"text":"otherwise","start":288010,"end":288450,"confidence":0.98502606,"speaker":"B"},{"text":"you","start":288450,"end":288570,"confidence":0.99902344,"speaker":"B"},{"text":"can","start":288570,"end":288690,"confidence":0.99902344,"speaker":"B"},{"text":"just.","start":288690,"end":288890,"confidence":1,"speaker":"B"},{"text":"You","start":288890,"end":289090,"confidence":0.99609375,"speaker":"B"},{"text":"could","start":289090,"end":289290,"confidence":0.9824219,"speaker":"B"},{"text":"start,","start":289290,"end":289610,"confidence":0.9995117,"speaker":"B"},{"text":"but","start":289850,"end":290250,"confidence":0.99902344,"speaker":"B"},{"text":"that'll","start":291130,"end":291530,"confidence":0.96761066,"speaker":"B"},{"text":"be","start":291530,"end":291610,"confidence":0.9995117,"speaker":"B"},{"text":"interesting.","start":291610,"end":291930,"confidence":0.99609375,"speaker":"B"},{"text":"Do","start":291930,"end":292090,"confidence":0.7919922,"speaker":"A"},{"text":"you","start":292090,"end":292170,"confidence":0.99560547,"speaker":"A"},{"text":"mind","start":292170,"end":292290,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":292290,"end":292450,"confidence":0.99560547,"speaker":"A"},{"text":"I","start":292450,"end":292650,"confidence":0.9995117,"speaker":"A"},{"text":"grab","start":292650,"end":292930,"confidence":1,"speaker":"A"},{"text":"a","start":292930,"end":293050,"confidence":0.9995117,"speaker":"A"},{"text":"cup","start":293050,"end":293170,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":293170,"end":293330,"confidence":0.9970703,"speaker":"A"},{"text":"coffee","start":293330,"end":293650,"confidence":0.9998372,"speaker":"A"},{"text":"real","start":293650,"end":293810,"confidence":0.9995117,"speaker":"A"},{"text":"quick?","start":293810,"end":294010,"confidence":1,"speaker":"A"},{"text":"No,","start":294010,"end":294250,"confidence":0.9975586,"speaker":"B"},{"text":"not","start":294250,"end":294450,"confidence":1,"speaker":"B"},{"text":"at","start":294450,"end":294570,"confidence":0.9995117,"speaker":"B"},{"text":"all.","start":294570,"end":294730,"confidence":1,"speaker":"B"},{"text":"Not","start":294730,"end":294930,"confidence":0.71875,"speaker":"A"},{"text":"at","start":294930,"end":295010,"confidence":0.8486328,"speaker":"A"},{"text":"all.","start":295010,"end":295210,"confidence":0.9042969,"speaker":"A"},{"text":"Okay,","start":295530,"end":296090,"confidence":0.9946289,"speaker":"A"},{"text":"cool.","start":296730,"end":297210,"confidence":0.99609375,"speaker":"A"},{"text":"I'm","start":297210,"end":297570,"confidence":0.8929036,"speaker":"A"},{"text":"not","start":297570,"end":297730,"confidence":1,"speaker":"A"},{"text":"using","start":297730,"end":297930,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":297930,"end":298090,"confidence":0.99609375,"speaker":"A"},{"text":"AirPods","start":298090,"end":298610,"confidence":0.96594,"speaker":"A"},{"text":"mic,","start":298610,"end":298930,"confidence":0.9863281,"speaker":"A"},{"text":"so","start":298930,"end":299250,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":299250,"end":299490,"confidence":1,"speaker":"A"},{"text":"can","start":299490,"end":299650,"confidence":0.9995117,"speaker":"A"},{"text":"hear","start":299650,"end":299810,"confidence":1,"speaker":"A"},{"text":"you,","start":299810,"end":299970,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":299970,"end":300130,"confidence":1,"speaker":"A"},{"text":"you","start":300130,"end":300290,"confidence":1,"speaker":"A"},{"text":"won't","start":300290,"end":300490,"confidence":0.9998372,"speaker":"A"},{"text":"be","start":300490,"end":300570,"confidence":1,"speaker":"A"},{"text":"able","start":300570,"end":300690,"confidence":1,"speaker":"A"},{"text":"to","start":300690,"end":300850,"confidence":1,"speaker":"A"},{"text":"hear","start":300850,"end":301050,"confidence":0.9995117,"speaker":"A"},{"text":"me.","start":301050,"end":301370,"confidence":0.9995117,"speaker":"A"},{"text":"Okay.","start":301690,"end":302250,"confidence":0.98746747,"speaker":"B"},{"text":"It's.","start":362440,"end":387820,"confidence":0.7732747,"speaker":"A"},{"text":"Thank","start":531699,"end":531940,"confidence":0.9851074,"speaker":"A"},{"text":"you","start":531940,"end":532260,"confidence":1,"speaker":"A"},{"text":"for","start":533860,"end":534220,"confidence":0.59277344,"speaker":"A"},{"text":"your","start":534220,"end":534500,"confidence":1,"speaker":"A"},{"text":"patience.","start":534500,"end":535060,"confidence":0.9992676,"speaker":"A"},{"text":"So","start":549010,"end":549130,"confidence":0.9873047,"speaker":"A"},{"text":"is","start":549130,"end":549290,"confidence":0.99365234,"speaker":"A"},{"text":"it","start":549290,"end":549450,"confidence":0.99902344,"speaker":"A"},{"text":"just","start":549450,"end":549650,"confidence":1,"speaker":"A"},{"text":"you?","start":549650,"end":549970,"confidence":0.9995117,"speaker":"A"},{"text":"It","start":551330,"end":551610,"confidence":0.95751953,"speaker":"B"},{"text":"looks","start":551610,"end":551810,"confidence":1,"speaker":"B"},{"text":"like","start":551810,"end":551930,"confidence":0.9995117,"speaker":"B"},{"text":"it's","start":551930,"end":552130,"confidence":0.9996745,"speaker":"B"},{"text":"just","start":552130,"end":552290,"confidence":1,"speaker":"B"},{"text":"me.","start":552290,"end":552570,"confidence":1,"speaker":"B"},{"text":"Josh","start":552570,"end":553010,"confidence":0.9995117,"speaker":"B"},{"text":"is","start":553010,"end":553290,"confidence":0.9970703,"speaker":"B"},{"text":"trying","start":553290,"end":553530,"confidence":0.9995117,"speaker":"B"},{"text":"to","start":553530,"end":553650,"confidence":1,"speaker":"B"},{"text":"get","start":553650,"end":553810,"confidence":1,"speaker":"B"},{"text":"in,","start":553810,"end":554010,"confidence":0.9995117,"speaker":"B"},{"text":"but","start":554010,"end":554170,"confidence":0.9995117,"speaker":"B"},{"text":"he's","start":554170,"end":554610,"confidence":0.92529297,"speaker":"B"},{"text":"trying","start":554610,"end":554930,"confidence":0.9995117,"speaker":"B"},{"text":"to","start":554930,"end":555090,"confidence":1,"speaker":"B"},{"text":"get","start":555090,"end":555210,"confidence":1,"speaker":"B"},{"text":"on","start":555210,"end":555490,"confidence":0.9272461,"speaker":"B"},{"text":"on","start":555650,"end":555970,"confidence":1,"speaker":"B"},{"text":"his","start":555970,"end":556210,"confidence":0.99902344,"speaker":"B"},{"text":"mobile","start":556210,"end":556530,"confidence":0.9998372,"speaker":"B"},{"text":"device","start":556530,"end":556810,"confidence":1,"speaker":"B"},{"text":"and","start":556810,"end":557010,"confidence":0.90478516,"speaker":"B"},{"text":"I","start":557010,"end":557210,"confidence":1,"speaker":"B"},{"text":"don't","start":557210,"end":557490,"confidence":0.98828125,"speaker":"B"},{"text":"think","start":557490,"end":557689,"confidence":1,"speaker":"B"},{"text":"that's","start":557689,"end":558010,"confidence":1,"speaker":"B"},{"text":"possible","start":558010,"end":558290,"confidence":1,"speaker":"B"},{"text":"with","start":558290,"end":558570,"confidence":0.9995117,"speaker":"B"},{"text":"Riverside.","start":558570,"end":559250,"confidence":0.9998372,"speaker":"B"},{"text":"Surprised?","start":563250,"end":563890,"confidence":0.9345703,"speaker":"A"},{"text":"I","start":564690,"end":564970,"confidence":0.9897461,"speaker":"A"},{"text":"mean,","start":564970,"end":565090,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":565090,"end":565210,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":565210,"end":565370,"confidence":1,"speaker":"A"},{"text":"they","start":565370,"end":565530,"confidence":1,"speaker":"A"},{"text":"have","start":565530,"end":565690,"confidence":1,"speaker":"A"},{"text":"an","start":565690,"end":565850,"confidence":0.99902344,"speaker":"A"},{"text":"app.","start":565850,"end":566130,"confidence":0.9863281,"speaker":"A"},{"text":"Maybe","start":567590,"end":567790,"confidence":0.93359375,"speaker":"B"},{"text":"he's","start":567790,"end":567990,"confidence":0.9996745,"speaker":"B"},{"text":"using.","start":567990,"end":568190,"confidence":0.99902344,"speaker":"B"},{"text":"I'm","start":568190,"end":568430,"confidence":0.99934894,"speaker":"B"},{"text":"not","start":568430,"end":568510,"confidence":0.99902344,"speaker":"B"},{"text":"sure","start":568510,"end":568630,"confidence":1,"speaker":"B"},{"text":"if","start":568630,"end":568710,"confidence":0.9980469,"speaker":"B"},{"text":"he's","start":568710,"end":568790,"confidence":0.9189453,"speaker":"B"},{"text":"using.","start":568790,"end":569030,"confidence":0.98535156,"speaker":"B"},{"text":"Using","start":569110,"end":569430,"confidence":1,"speaker":"B"},{"text":"the","start":569430,"end":569630,"confidence":0.99902344,"speaker":"B"},{"text":"app","start":569630,"end":569790,"confidence":0.9995117,"speaker":"B"},{"text":"or","start":569790,"end":569910,"confidence":0.9995117,"speaker":"B"},{"text":"not.","start":569910,"end":570070,"confidence":0.9995117,"speaker":"B"},{"text":"Okay.","start":570070,"end":570550,"confidence":0.99820966,"speaker":"A"},{"text":"Should","start":575190,"end":575470,"confidence":0.99658203,"speaker":"A"},{"text":"I","start":575470,"end":575630,"confidence":0.8354492,"speaker":"A"},{"text":"just","start":575630,"end":575910,"confidence":1,"speaker":"A"},{"text":"go?","start":575910,"end":576310,"confidence":1,"speaker":"A"},{"text":"Sure.","start":578230,"end":578630,"confidence":1,"speaker":"B"},{"text":"Okay.","start":579830,"end":580470,"confidence":0.91015625,"speaker":"A"},{"text":"Well,","start":582390,"end":582710,"confidence":0.9980469,"speaker":"A"},{"text":"thanks","start":582710,"end":583030,"confidence":0.9926758,"speaker":"A"},{"text":"for","start":583030,"end":583230,"confidence":1,"speaker":"A"},{"text":"joining","start":583230,"end":583549,"confidence":0.75911456,"speaker":"A"},{"text":"me,","start":583549,"end":583830,"confidence":0.99902344,"speaker":"A"},{"text":"Evan.","start":583830,"end":584310,"confidence":0.9511719,"speaker":"A"},{"text":"I","start":584310,"end":584510,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":584510,"end":584670,"confidence":0.9995117,"speaker":"A"},{"text":"appreciate","start":584670,"end":584990,"confidence":0.9088135,"speaker":"A"},{"text":"it.","start":584990,"end":585270,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":587430,"end":587670,"confidence":0.8666992,"speaker":"A"},{"text":"would","start":587670,"end":587790,"confidence":0.67871094,"speaker":"A"},{"text":"say","start":587790,"end":588070,"confidence":0.9448242,"speaker":"A"},{"text":"no.","start":588390,"end":588630,"confidence":0.9951172,"speaker":"A"},{"text":"I","start":588630,"end":588710,"confidence":0.9995117,"speaker":"A"},{"text":"mean","start":588710,"end":588830,"confidence":0.95947266,"speaker":"A"},{"text":"I","start":588830,"end":588990,"confidence":0.99902344,"speaker":"A"},{"text":"do,","start":588990,"end":589270,"confidence":1,"speaker":"A"},{"text":"seriously.","start":589270,"end":589910,"confidence":0.99934894,"speaker":"A"},{"text":"So","start":591830,"end":592110,"confidence":0.9995117,"speaker":"A"},{"text":"yeah,","start":592110,"end":592470,"confidence":1,"speaker":"A"},{"text":"this","start":592630,"end":592910,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":592910,"end":593030,"confidence":0.79296875,"speaker":"A"},{"text":"a","start":593030,"end":593150,"confidence":0.6645508,"speaker":"A"},{"text":"kind","start":593150,"end":593310,"confidence":0.99853516,"speaker":"A"},{"text":"of","start":593310,"end":593430,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":593430,"end":593550,"confidence":0.99609375,"speaker":"A"},{"text":"dry","start":593550,"end":593830,"confidence":0.8828125,"speaker":"A"},{"text":"run.","start":593830,"end":594150,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":594710,"end":594830,"confidence":0.9941406,"speaker":"A"},{"text":"would","start":594830,"end":594950,"confidence":0.9980469,"speaker":"A"},{"text":"say","start":594950,"end":595070,"confidence":0.99560547,"speaker":"A"},{"text":"I'm","start":595070,"end":595270,"confidence":0.99869794,"speaker":"A"},{"text":"about","start":595270,"end":595470,"confidence":0.9995117,"speaker":"A"},{"text":"60%","start":595470,"end":596110,"confidence":0.92505,"speaker":"A"},{"text":"done","start":596110,"end":596350,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":596350,"end":596510,"confidence":1,"speaker":"A"},{"text":"this","start":596510,"end":596710,"confidence":0.99853516,"speaker":"A"},{"text":"presentation","start":596710,"end":597350,"confidence":1,"speaker":"A"},{"text":"about","start":599270,"end":599670,"confidence":0.9975586,"speaker":"A"},{"text":"CloudKit","start":600310,"end":600990,"confidence":0.7687988,"speaker":"A"},{"text":"on","start":600990,"end":601150,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":601150,"end":601310,"confidence":0.9946289,"speaker":"A"},{"text":"server","start":601310,"end":601750,"confidence":0.7963867,"speaker":"A"},{"text":"and","start":604070,"end":604470,"confidence":0.9892578,"speaker":"A"},{"text":"we'll","start":604870,"end":605230,"confidence":0.9514974,"speaker":"A"},{"text":"probably","start":605230,"end":605470,"confidence":1,"speaker":"A"},{"text":"hop","start":605470,"end":605710,"confidence":0.9946289,"speaker":"A"},{"text":"back","start":605710,"end":605950,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":605950,"end":606110,"confidence":1,"speaker":"A"},{"text":"forth","start":606110,"end":606350,"confidence":1,"speaker":"A"},{"text":"between","start":606350,"end":606630,"confidence":1,"speaker":"A"},{"text":"Keynote","start":606630,"end":607230,"confidence":0.88049316,"speaker":"A"},{"text":"and","start":607230,"end":607390,"confidence":0.9975586,"speaker":"A"},{"text":"not","start":607390,"end":607590,"confidence":0.9458008,"speaker":"A"},{"text":"Keynote,","start":607590,"end":608310,"confidence":0.99328613,"speaker":"A"},{"text":"but","start":608870,"end":609270,"confidence":0.9941406,"speaker":"A"},{"text":"yeah.","start":609510,"end":609990,"confidence":0.9737956,"speaker":"A"},{"text":"So","start":611670,"end":611950,"confidence":0.9946289,"speaker":"A"},{"text":"this","start":611950,"end":612110,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":612110,"end":612310,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":612310,"end":612910,"confidence":0.92456055,"speaker":"A"},{"text":"as","start":612910,"end":613070,"confidence":0.9863281,"speaker":"A"},{"text":"your","start":613070,"end":613230,"confidence":0.94628906,"speaker":"A"},{"text":"backend","start":613230,"end":613750,"confidence":0.8310547,"speaker":"A"},{"text":"from","start":613910,"end":614310,"confidence":1,"speaker":"A"},{"text":"iOS","start":614310,"end":614870,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":615030,"end":615390,"confidence":0.9941406,"speaker":"A"},{"text":"server","start":615390,"end":615830,"confidence":0.9873047,"speaker":"A"},{"text":"side","start":615830,"end":616070,"confidence":0.5727539,"speaker":"A"},{"text":"Swift.","start":616070,"end":616630,"confidence":0.9953613,"speaker":"A"},{"text":"So","start":627600,"end":627840,"confidence":0.9916992,"speaker":"A"},{"text":"what","start":628160,"end":628480,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":628480,"end":628720,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit?","start":628720,"end":629440,"confidence":0.88281,"speaker":"A"},{"text":"CloudKit","start":629600,"end":630320,"confidence":0.88281,"speaker":"A"},{"text":"is","start":630320,"end":630600,"confidence":0.9921875,"speaker":"A"},{"text":"a","start":630600,"end":630880,"confidence":0.99853516,"speaker":"A"},{"text":"service","start":630880,"end":631200,"confidence":0.9995117,"speaker":"A"},{"text":"launched","start":632240,"end":632680,"confidence":0.99731445,"speaker":"A"},{"text":"by","start":632680,"end":632840,"confidence":1,"speaker":"A"},{"text":"Apple","start":632840,"end":633360,"confidence":1,"speaker":"A"},{"text":"probably","start":633600,"end":634000,"confidence":0.99869794,"speaker":"A"},{"text":"a","start":634000,"end":634160,"confidence":0.9995117,"speaker":"A"},{"text":"decade","start":634160,"end":634520,"confidence":0.99975586,"speaker":"A"},{"text":"ago","start":634520,"end":634800,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":635920,"end":636279,"confidence":0.9848633,"speaker":"A"},{"text":"kind","start":636279,"end":636520,"confidence":0.8803711,"speaker":"A"},{"text":"of","start":636520,"end":636800,"confidence":0.98828125,"speaker":"A"},{"text":"give","start":636960,"end":637360,"confidence":0.9995117,"speaker":"A"},{"text":"developers","start":638880,"end":639680,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":639840,"end":640200,"confidence":0.99902344,"speaker":"A"},{"text":"built","start":640200,"end":640520,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":640520,"end":640720,"confidence":0.99316406,"speaker":"A"},{"text":"back","start":640720,"end":641000,"confidence":0.9995117,"speaker":"A"},{"text":"end","start":641000,"end":641280,"confidence":0.58935547,"speaker":"A"},{"text":"for","start":641280,"end":641520,"confidence":0.99609375,"speaker":"A"},{"text":"storing","start":641520,"end":641960,"confidence":0.9946289,"speaker":"A"},{"text":"data","start":641960,"end":642240,"confidence":0.99902344,"speaker":"A"},{"text":"for","start":642640,"end":642920,"confidence":0.9995117,"speaker":"A"},{"text":"their","start":642920,"end":643160,"confidence":0.99853516,"speaker":"A"},{"text":"apps.","start":643160,"end":643680,"confidence":0.99902344,"speaker":"A"},{"text":"One","start":644480,"end":644760,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":644760,"end":644880,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":644880,"end":645000,"confidence":0.99853516,"speaker":"A"},{"text":"biggest","start":645000,"end":645360,"confidence":1,"speaker":"A"},{"text":"benefits","start":645360,"end":646000,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":646080,"end":646300,"confidence":0.84765625,"speaker":"A"},{"text":"is","start":646450,"end":646690,"confidence":0.9736328,"speaker":"A"},{"text":"how","start":646690,"end":647090,"confidence":0.9995117,"speaker":"A"},{"text":"cheap","start":647090,"end":647450,"confidence":0.9998372,"speaker":"A"},{"text":"it","start":647450,"end":647610,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":647610,"end":647890,"confidence":0.9980469,"speaker":"A"},{"text":"to","start":647970,"end":648250,"confidence":0.99853516,"speaker":"A"},{"text":"use","start":648250,"end":648490,"confidence":0.9970703,"speaker":"A"},{"text":"for","start":648490,"end":648810,"confidence":0.9995117,"speaker":"A"},{"text":"iOS","start":648810,"end":649290,"confidence":0.9992676,"speaker":"A"},{"text":"developers.","start":649290,"end":649970,"confidence":0.998291,"speaker":"A"},{"text":"So","start":652450,"end":652850,"confidence":0.95751953,"speaker":"A"},{"text":"if","start":653570,"end":653850,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":653850,"end":654130,"confidence":1,"speaker":"A"},{"text":"have","start":654450,"end":654850,"confidence":0.99902344,"speaker":"A"},{"text":"built","start":655330,"end":655690,"confidence":0.99934894,"speaker":"A"},{"text":"an","start":655690,"end":655850,"confidence":0.99560547,"speaker":"A"},{"text":"app,","start":655850,"end":656130,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":656290,"end":656570,"confidence":1,"speaker":"A"},{"text":"could","start":656570,"end":656730,"confidence":0.6508789,"speaker":"A"},{"text":"just","start":656730,"end":656930,"confidence":0.99902344,"speaker":"A"},{"text":"add","start":656930,"end":657250,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":657410,"end":658290,"confidence":0.89294,"speaker":"A"},{"text":"right","start":658290,"end":658610,"confidence":0.99853516,"speaker":"A"},{"text":"here","start":658610,"end":658930,"confidence":0.9995117,"speaker":"A"},{"text":"within","start":659570,"end":659970,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":661330,"end":661730,"confidence":0.9970703,"speaker":"A"},{"text":"Xcode","start":662209,"end":662770,"confidence":0.91137695,"speaker":"A"},{"text":"project","start":662770,"end":663090,"confidence":1,"speaker":"A"},{"text":"and","start":663490,"end":663890,"confidence":0.9975586,"speaker":"A"},{"text":"use","start":665330,"end":665690,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":665690,"end":665970,"confidence":0.9995117,"speaker":"A"},{"text":"regular","start":665970,"end":666370,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":666370,"end":666970,"confidence":0.9975586,"speaker":"A"},{"text":"API","start":666970,"end":667490,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":667890,"end":668170,"confidence":0.5913086,"speaker":"A"},{"text":"Swift","start":668170,"end":668570,"confidence":0.9951172,"speaker":"A"},{"text":"to","start":668570,"end":668810,"confidence":0.99902344,"speaker":"A"},{"text":"go","start":668810,"end":668970,"confidence":0.9975586,"speaker":"A"},{"text":"ahead","start":668970,"end":669250,"confidence":0.9765625,"speaker":"A"},{"text":"and","start":669250,"end":669530,"confidence":0.99902344,"speaker":"A"},{"text":"start","start":669530,"end":669730,"confidence":1,"speaker":"A"},{"text":"using","start":669730,"end":669930,"confidence":1,"speaker":"A"},{"text":"it","start":669930,"end":670130,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":670130,"end":670330,"confidence":0.99902344,"speaker":"A"},{"text":"your","start":670330,"end":670530,"confidence":1,"speaker":"A"},{"text":"app.","start":670530,"end":670850,"confidence":0.9975586,"speaker":"A"},{"text":"Here","start":673390,"end":673630,"confidence":0.9946289,"speaker":"A"},{"text":"is","start":673630,"end":674030,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":674030,"end":674430,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":674430,"end":674750,"confidence":0.9980469,"speaker":"A"},{"text":"looks","start":674750,"end":675110,"confidence":1,"speaker":"A"},{"text":"like","start":675110,"end":675390,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":675390,"end":675750,"confidence":0.99902344,"speaker":"A"},{"text":"create","start":675750,"end":675990,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":675990,"end":676110,"confidence":0.9868164,"speaker":"A"},{"text":"new","start":676110,"end":676270,"confidence":0.99853516,"speaker":"A"},{"text":"record","start":676270,"end":676590,"confidence":0.9995117,"speaker":"A"},{"text":"type.","start":676590,"end":676990,"confidence":0.99194336,"speaker":"A"},{"text":"You","start":676990,"end":677150,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":677150,"end":677270,"confidence":1,"speaker":"A"},{"text":"do","start":677270,"end":677430,"confidence":1,"speaker":"A"},{"text":"all","start":677430,"end":677590,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":677590,"end":677870,"confidence":0.99853516,"speaker":"A"},{"text":"through","start":677870,"end":678270,"confidence":1,"speaker":"A"},{"text":"the","start":678430,"end":678790,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":678790,"end":679510,"confidence":0.9987793,"speaker":"A"},{"text":"dashboard.","start":679510,"end":680190,"confidence":0.99938965,"speaker":"A"},{"text":"In","start":684190,"end":684470,"confidence":0.7402344,"speaker":"A"},{"text":"CloudKit","start":684470,"end":685150,"confidence":0.9477539,"speaker":"A"},{"text":"you","start":685390,"end":685670,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":685670,"end":685830,"confidence":0.8930664,"speaker":"A"},{"text":"also","start":685830,"end":686030,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":686030,"end":686230,"confidence":1,"speaker":"A"},{"text":"this","start":686230,"end":686470,"confidence":1,"speaker":"A"},{"text":"using","start":686470,"end":686830,"confidence":1,"speaker":"A"},{"text":"a","start":687150,"end":687430,"confidence":0.94921875,"speaker":"A"},{"text":"schema","start":687430,"end":687910,"confidence":0.9895833,"speaker":"A"},{"text":"file","start":687910,"end":688270,"confidence":0.8520508,"speaker":"A"},{"text":"too.","start":688670,"end":689070,"confidence":0.8598633,"speaker":"A"},{"text":"And","start":689390,"end":689670,"confidence":0.99316406,"speaker":"A"},{"text":"you","start":689670,"end":689830,"confidence":0.98583984,"speaker":"A"},{"text":"can","start":689830,"end":689990,"confidence":0.6220703,"speaker":"A"},{"text":"export","start":689990,"end":690310,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":690310,"end":690470,"confidence":0.9692383,"speaker":"A"},{"text":"import","start":690470,"end":690750,"confidence":0.9970703,"speaker":"A"},{"text":"your","start":690830,"end":691150,"confidence":0.99902344,"speaker":"A"},{"text":"schema","start":691150,"end":691710,"confidence":0.92041016,"speaker":"A"},{"text":"that","start":691710,"end":692030,"confidence":0.99658203,"speaker":"A"},{"text":"way.","start":692030,"end":692350,"confidence":0.9975586,"speaker":"A"},{"text":"And","start":693230,"end":693630,"confidence":0.98046875,"speaker":"A"},{"text":"it's","start":693630,"end":694070,"confidence":0.9996745,"speaker":"A"},{"text":"not","start":694070,"end":694350,"confidence":0.9980469,"speaker":"A"},{"text":"a","start":694590,"end":694870,"confidence":0.9321289,"speaker":"A"},{"text":"SQL","start":694870,"end":695190,"confidence":0.9423828,"speaker":"A"},{"text":"based","start":695190,"end":695430,"confidence":0.99902344,"speaker":"A"},{"text":"database,","start":695430,"end":696030,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":696030,"end":696270,"confidence":0.97802734,"speaker":"A"},{"text":"much","start":696270,"end":696470,"confidence":0.9980469,"speaker":"A"},{"text":"more,","start":696470,"end":696830,"confidence":0.9892578,"speaker":"A"},{"text":"no","start":697310,"end":697670,"confidence":0.9902344,"speaker":"A"},{"text":"sequel","start":697670,"end":698110,"confidence":0.8517253,"speaker":"A"},{"text":"ish","start":698110,"end":698430,"confidence":0.9033203,"speaker":"A"},{"text":"or","start":698430,"end":698630,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":698630,"end":698830,"confidence":0.9770508,"speaker":"A"},{"text":"abstract","start":698830,"end":699350,"confidence":0.9822591,"speaker":"A"},{"text":"layer","start":699350,"end":699910,"confidence":0.99886066,"speaker":"A"},{"text":"above","start":699910,"end":700230,"confidence":0.98461914,"speaker":"A"},{"text":"it.","start":700230,"end":700510,"confidence":0.99609375,"speaker":"A"},{"text":"But","start":701400,"end":701560,"confidence":0.99658203,"speaker":"A"},{"text":"essentially","start":701560,"end":702240,"confidence":0.97021484,"speaker":"A"},{"text":"you","start":702240,"end":702600,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":702680,"end":703080,"confidence":0.9995117,"speaker":"A"},{"text":"create","start":703080,"end":703440,"confidence":0.9970703,"speaker":"A"},{"text":"records","start":703440,"end":704120,"confidence":0.99658203,"speaker":"A"},{"text":"kind","start":704520,"end":704800,"confidence":0.99658203,"speaker":"A"},{"text":"of","start":704800,"end":704920,"confidence":0.9970703,"speaker":"A"},{"text":"like","start":704920,"end":705040,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":705040,"end":705200,"confidence":0.9995117,"speaker":"A"},{"text":"table","start":705200,"end":705480,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":705480,"end":705680,"confidence":0.99902344,"speaker":"A"},{"text":"not","start":705680,"end":705880,"confidence":0.99853516,"speaker":"A"},{"text":"quite","start":705880,"end":706280,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":707000,"end":707280,"confidence":0.98339844,"speaker":"A"},{"text":"your","start":707280,"end":707520,"confidence":0.9970703,"speaker":"A"},{"text":"records.","start":707520,"end":708200,"confidence":0.9963379,"speaker":"A"},{"text":"You","start":709400,"end":709680,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":709680,"end":709960,"confidence":0.9995117,"speaker":"A"},{"text":"create","start":710360,"end":710760,"confidence":0.9824219,"speaker":"A"},{"text":"a","start":711400,"end":711760,"confidence":0.9980469,"speaker":"A"},{"text":"struct","start":711760,"end":712240,"confidence":0.83862305,"speaker":"A"},{"text":"for","start":712240,"end":712480,"confidence":0.99902344,"speaker":"A"},{"text":"it.","start":712480,"end":712680,"confidence":0.9980469,"speaker":"A"},{"text":"You","start":712680,"end":712880,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":712880,"end":713040,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":713040,"end":713240,"confidence":1,"speaker":"A"},{"text":"use","start":713240,"end":713560,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":713960,"end":714600,"confidence":0.982666,"speaker":"A"},{"text":"directly","start":714600,"end":715120,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":715120,"end":715360,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":715360,"end":715520,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":715520,"end":715800,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":716440,"end":716760,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":716760,"end":717039,"confidence":0.99072266,"speaker":"A"},{"text":"you","start":717039,"end":717280,"confidence":0.98535156,"speaker":"A"},{"text":"can","start":717280,"end":717480,"confidence":0.88964844,"speaker":"A"},{"text":"then","start":717480,"end":717760,"confidence":0.78759766,"speaker":"A"},{"text":"plug","start":717760,"end":718080,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":718080,"end":718240,"confidence":0.99902344,"speaker":"A"},{"text":"into","start":718240,"end":718440,"confidence":0.99902344,"speaker":"A"},{"text":"your","start":718440,"end":718680,"confidence":0.9995117,"speaker":"A"},{"text":"app","start":718680,"end":718920,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":718920,"end":719240,"confidence":0.9628906,"speaker":"A"},{"text":"do","start":719240,"end":719520,"confidence":0.9995117,"speaker":"A"},{"text":"fun","start":719520,"end":719760,"confidence":0.99853516,"speaker":"A"},{"text":"stuff","start":719760,"end":720040,"confidence":1,"speaker":"A"},{"text":"like","start":720040,"end":720200,"confidence":0.9995117,"speaker":"A"},{"text":"this.","start":720200,"end":720520,"confidence":0.9946289,"speaker":"A"},{"text":"We","start":721560,"end":721880,"confidence":0.44580078,"speaker":"A"},{"text":"can","start":721880,"end":722080,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":722080,"end":722240,"confidence":1,"speaker":"A"},{"text":"things","start":722240,"end":722440,"confidence":1,"speaker":"A"},{"text":"like","start":722440,"end":722760,"confidence":0.9995117,"speaker":"A"},{"text":"queries","start":722840,"end":723520,"confidence":0.9477539,"speaker":"A"},{"text":"and","start":723520,"end":723880,"confidence":0.8354492,"speaker":"A"},{"text":"basic","start":724840,"end":725280,"confidence":0.99975586,"speaker":"A"},{"text":"database","start":725280,"end":725800,"confidence":0.99869794,"speaker":"A"},{"text":"stuff.","start":725800,"end":726200,"confidence":0.9996745,"speaker":"A"},{"text":"There's","start":726200,"end":726640,"confidence":0.99153644,"speaker":"A"},{"text":"a","start":726640,"end":726760,"confidence":0.99902344,"speaker":"A"},{"text":"lot","start":726760,"end":726840,"confidence":1,"speaker":"A"},{"text":"of","start":726840,"end":726960,"confidence":0.99902344,"speaker":"A"},{"text":"advantages","start":726960,"end":727520,"confidence":0.9991862,"speaker":"A"},{"text":"to","start":727520,"end":727760,"confidence":0.99853516,"speaker":"A"},{"text":"it.","start":727760,"end":728040,"confidence":0.99658203,"speaker":"A"},{"text":"For","start":729280,"end":729440,"confidence":0.9794922,"speaker":"A"},{"text":"one,","start":729440,"end":729760,"confidence":0.9667969,"speaker":"A"},{"text":"if","start":730080,"end":730400,"confidence":0.9995117,"speaker":"A"},{"text":"you're","start":730400,"end":730880,"confidence":0.95996094,"speaker":"A"},{"text":"doing","start":730960,"end":731360,"confidence":0.99902344,"speaker":"A"},{"text":"Apple","start":731840,"end":732320,"confidence":1,"speaker":"A"},{"text":"only,","start":732320,"end":732640,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":733600,"end":734000,"confidence":0.99658203,"speaker":"A"},{"text":"it","start":734000,"end":734280,"confidence":0.9995117,"speaker":"A"},{"text":"definitely","start":734280,"end":734680,"confidence":0.99938965,"speaker":"A"},{"text":"makes","start":734680,"end":734880,"confidence":0.9980469,"speaker":"A"},{"text":"sense","start":734880,"end":735280,"confidence":0.99975586,"speaker":"A"},{"text":"to","start":735520,"end":735840,"confidence":0.99853516,"speaker":"A"},{"text":"look","start":735840,"end":736120,"confidence":0.98046875,"speaker":"A"},{"text":"into,","start":736120,"end":736440,"confidence":0.53515625,"speaker":"A"},{"text":"at","start":736440,"end":736640,"confidence":0.9995117,"speaker":"A"},{"text":"least","start":736640,"end":736800,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":736800,"end":737040,"confidence":0.99902344,"speaker":"A"},{"text":"into","start":737040,"end":737320,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit.","start":737320,"end":738080,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":742320,"end":742600,"confidence":0.9980469,"speaker":"A"},{"text":"you're","start":742600,"end":742800,"confidence":0.9996745,"speaker":"A"},{"text":"just","start":742800,"end":742920,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":742920,"end":743040,"confidence":0.92333984,"speaker":"A"},{"text":"to","start":743040,"end":743120,"confidence":0.99902344,"speaker":"A"},{"text":"deploy","start":743120,"end":743480,"confidence":1,"speaker":"A"},{"text":"to","start":743480,"end":743840,"confidence":0.99316406,"speaker":"A"},{"text":"Apple","start":744480,"end":744960,"confidence":0.99975586,"speaker":"A"},{"text":"Devices.","start":744960,"end":745440,"confidence":1,"speaker":"A"},{"text":"If","start":746080,"end":746440,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":746440,"end":746800,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":747120,"end":747560,"confidence":0.9637044,"speaker":"A"},{"text":"mind","start":747560,"end":747920,"confidence":0.9995117,"speaker":"A"},{"text":"the,","start":748320,"end":748720,"confidence":0.9042969,"speaker":"A"},{"text":"the","start":749920,"end":750200,"confidence":0.9995117,"speaker":"A"},{"text":"fact","start":750200,"end":750360,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":750360,"end":750520,"confidence":1,"speaker":"A"},{"text":"it's","start":750520,"end":750720,"confidence":0.9996745,"speaker":"A"},{"text":"not","start":750720,"end":750920,"confidence":0.84814453,"speaker":"A"},{"text":"a","start":750920,"end":751160,"confidence":0.5908203,"speaker":"A"},{"text":"regular","start":751160,"end":751560,"confidence":0.9992676,"speaker":"A"},{"text":"SQL","start":751560,"end":751960,"confidence":0.98860675,"speaker":"A"},{"text":"database,","start":751960,"end":752640,"confidence":0.9998372,"speaker":"A"},{"text":"that's","start":754050,"end":754210,"confidence":0.9980469,"speaker":"A"},{"text":"something","start":754210,"end":754410,"confidence":0.9995117,"speaker":"A"},{"text":"too","start":754410,"end":754650,"confidence":0.68408203,"speaker":"A"},{"text":"to","start":754650,"end":754810,"confidence":0.99853516,"speaker":"A"},{"text":"think","start":754810,"end":754930,"confidence":1,"speaker":"A"},{"text":"about.","start":754930,"end":755090,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":755090,"end":755290,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":755290,"end":755450,"confidence":1,"speaker":"A"},{"text":"like","start":755450,"end":755610,"confidence":0.92333984,"speaker":"A"},{"text":"need","start":755610,"end":755770,"confidence":0.9848633,"speaker":"A"},{"text":"a","start":755770,"end":755890,"confidence":0.9926758,"speaker":"A"},{"text":"SQL","start":755890,"end":756210,"confidence":0.96533203,"speaker":"A"},{"text":"database,","start":756210,"end":756650,"confidence":0.98063153,"speaker":"A"},{"text":"this","start":756650,"end":756850,"confidence":0.97998047,"speaker":"A"},{"text":"might","start":756850,"end":757050,"confidence":1,"speaker":"A"},{"text":"not","start":757050,"end":757210,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":757210,"end":757490,"confidence":1,"speaker":"A"},{"text":"what","start":757730,"end":758050,"confidence":0.9819336,"speaker":"A"},{"text":"you","start":758050,"end":758370,"confidence":0.9995117,"speaker":"A"},{"text":"want.","start":758370,"end":758770,"confidence":0.9926758,"speaker":"A"},{"text":"And","start":759410,"end":759690,"confidence":0.95654297,"speaker":"A"},{"text":"then","start":759690,"end":759890,"confidence":0.9819336,"speaker":"A"},{"text":"if","start":759890,"end":760050,"confidence":1,"speaker":"A"},{"text":"you","start":760050,"end":760170,"confidence":1,"speaker":"A"},{"text":"don't","start":760170,"end":760370,"confidence":1,"speaker":"A"},{"text":"mind","start":760370,"end":760530,"confidence":1,"speaker":"A"},{"text":"working","start":760530,"end":760770,"confidence":1,"speaker":"A"},{"text":"with","start":760770,"end":761010,"confidence":0.9848633,"speaker":"A"},{"text":"a","start":761010,"end":761170,"confidence":0.99902344,"speaker":"A"},{"text":"lot","start":761170,"end":761290,"confidence":1,"speaker":"A"},{"text":"of","start":761290,"end":761410,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":761410,"end":761530,"confidence":0.9995117,"speaker":"A"},{"text":"abstraction","start":761530,"end":762130,"confidence":0.9991455,"speaker":"A"},{"text":"layers","start":762130,"end":762610,"confidence":0.99934894,"speaker":"A"},{"text":"that","start":763010,"end":763330,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":763330,"end":763970,"confidence":0.99902344,"speaker":"A"},{"text":"provides,","start":763970,"end":764610,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":766930,"end":767330,"confidence":0.99658203,"speaker":"A"},{"text":"this","start":767650,"end":767970,"confidence":0.9995117,"speaker":"A"},{"text":"might","start":767970,"end":768170,"confidence":0.99609375,"speaker":"A"},{"text":"be","start":768170,"end":768370,"confidence":1,"speaker":"A"},{"text":"good","start":768370,"end":768530,"confidence":1,"speaker":"A"},{"text":"for","start":768530,"end":768650,"confidence":0.87402344,"speaker":"A"},{"text":"you","start":768650,"end":768850,"confidence":1,"speaker":"A"},{"text":"to","start":768850,"end":769050,"confidence":1,"speaker":"A"},{"text":"get","start":769050,"end":769210,"confidence":1,"speaker":"A"},{"text":"started","start":769210,"end":769490,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":770050,"end":770410,"confidence":0.99658203,"speaker":"A"},{"text":"especially","start":770410,"end":770730,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":770730,"end":770930,"confidence":1,"speaker":"A"},{"text":"you","start":770930,"end":771050,"confidence":1,"speaker":"A"},{"text":"don't","start":771050,"end":771250,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":771250,"end":771370,"confidence":1,"speaker":"A"},{"text":"any","start":771370,"end":771570,"confidence":0.9995117,"speaker":"A"},{"text":"database","start":771570,"end":772130,"confidence":0.9998372,"speaker":"A"},{"text":"experience.","start":772130,"end":772450,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":774130,"end":774410,"confidence":0.99316406,"speaker":"A"},{"text":"as","start":774410,"end":774570,"confidence":0.9995117,"speaker":"A"},{"text":"far","start":774570,"end":774730,"confidence":1,"speaker":"A"},{"text":"as","start":774730,"end":774930,"confidence":1,"speaker":"A"},{"text":"like","start":774930,"end":775250,"confidence":0.9770508,"speaker":"A"},{"text":"server","start":775570,"end":776090,"confidence":0.99975586,"speaker":"A"},{"text":"choices,","start":776090,"end":776650,"confidence":0.98291016,"speaker":"A"},{"text":"I","start":776650,"end":776850,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":776850,"end":777010,"confidence":1,"speaker":"A"},{"text":"say","start":777010,"end":777290,"confidence":1,"speaker":"A"},{"text":"CloudKit","start":777290,"end":777970,"confidence":0.9926758,"speaker":"A"},{"text":"might","start":777970,"end":778170,"confidence":0.99365234,"speaker":"A"},{"text":"not","start":778170,"end":778330,"confidence":0.57714844,"speaker":"A"},{"text":"be","start":778330,"end":778490,"confidence":1,"speaker":"A"},{"text":"your","start":778490,"end":778690,"confidence":1,"speaker":"A"},{"text":"first","start":778690,"end":778930,"confidence":0.9995117,"speaker":"A"},{"text":"choice,","start":778930,"end":779330,"confidence":0.99975586,"speaker":"A"},{"text":"but","start":779970,"end":780090,"confidence":0.9970703,"speaker":"A"},{"text":"it","start":780090,"end":780250,"confidence":0.99902344,"speaker":"A"},{"text":"certainly","start":780250,"end":780610,"confidence":1,"speaker":"A"},{"text":"is","start":780610,"end":780930,"confidence":1,"speaker":"A"},{"text":"a","start":780930,"end":781210,"confidence":0.9995117,"speaker":"A"},{"text":"decent","start":781210,"end":781570,"confidence":1,"speaker":"A"},{"text":"choice","start":781570,"end":781970,"confidence":0.99975586,"speaker":"A"},{"text":"if","start":782290,"end":782610,"confidence":0.6225586,"speaker":"A"},{"text":"you're","start":782610,"end":782890,"confidence":0.9943034,"speaker":"A"},{"text":"going","start":782890,"end":783090,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":783090,"end":783290,"confidence":0.9145508,"speaker":"A"},{"text":"Apple","start":783290,"end":783650,"confidence":0.9995117,"speaker":"A"},{"text":"only","start":783650,"end":783970,"confidence":0.9995117,"speaker":"A"},{"text":"route.","start":783970,"end":784450,"confidence":0.9938965,"speaker":"A"},{"text":"But","start":789970,"end":790250,"confidence":0.99658203,"speaker":"A"},{"text":"then","start":790250,"end":790410,"confidence":1,"speaker":"A"},{"text":"the","start":790410,"end":790530,"confidence":1,"speaker":"A"},{"text":"question","start":790530,"end":790730,"confidence":1,"speaker":"A"},{"text":"comes","start":790730,"end":791010,"confidence":0.9951172,"speaker":"A"},{"text":"in,","start":791010,"end":791250,"confidence":0.97216797,"speaker":"A"},{"text":"why","start":791250,"end":791450,"confidence":1,"speaker":"A"},{"text":"would","start":791450,"end":791610,"confidence":1,"speaker":"A"},{"text":"you","start":791610,"end":791770,"confidence":1,"speaker":"A"},{"text":"want","start":791770,"end":792010,"confidence":0.99902344,"speaker":"A"},{"text":"Cloud","start":792010,"end":792450,"confidence":0.954834,"speaker":"A"},{"text":"server","start":792450,"end":792850,"confidence":0.98461914,"speaker":"A"},{"text":"side","start":792850,"end":793050,"confidence":0.55859375,"speaker":"A"},{"text":"CloudKit?","start":793050,"end":793730,"confidence":0.98095703,"speaker":"A"},{"text":"Why","start":793890,"end":794170,"confidence":1,"speaker":"A"},{"text":"would","start":794170,"end":794330,"confidence":1,"speaker":"A"},{"text":"you","start":794330,"end":794490,"confidence":1,"speaker":"A"},{"text":"want","start":794490,"end":794610,"confidence":0.9941406,"speaker":"A"},{"text":"to","start":794610,"end":794690,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":794690,"end":794810,"confidence":1,"speaker":"A"},{"text":"anything","start":794810,"end":795090,"confidence":1,"speaker":"A"},{"text":"with","start":795090,"end":795250,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":795250,"end":795810,"confidence":0.9885254,"speaker":"A"},{"text":"on","start":795810,"end":796009,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":796009,"end":796170,"confidence":0.9995117,"speaker":"A"},{"text":"server?","start":796170,"end":796610,"confidence":1,"speaker":"A"},{"text":"So","start":797970,"end":798250,"confidence":0.99316406,"speaker":"A"},{"text":"here's,","start":798250,"end":798610,"confidence":0.9793294,"speaker":"A"},{"text":"here's","start":798610,"end":799090,"confidence":0.9996745,"speaker":"A"},{"text":"the","start":799250,"end":799530,"confidence":0.9995117,"speaker":"A"},{"text":"first","start":799530,"end":799810,"confidence":0.9995117,"speaker":"A"},{"text":"case.","start":799890,"end":800290,"confidence":0.9995117,"speaker":"A"},{"text":"Well,","start":800690,"end":801090,"confidence":0.96533203,"speaker":"A"},{"text":"this","start":801250,"end":801530,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":801530,"end":801690,"confidence":1,"speaker":"A"},{"text":"how","start":801690,"end":801890,"confidence":1,"speaker":"A"},{"text":"you","start":801890,"end":802090,"confidence":1,"speaker":"A"},{"text":"can","start":802090,"end":802290,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":802290,"end":802490,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":802490,"end":802650,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":802650,"end":802850,"confidence":0.97216797,"speaker":"A"},{"text":"do","start":802850,"end":803050,"confidence":1,"speaker":"A"},{"text":"that","start":803050,"end":803250,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":803250,"end":803570,"confidence":0.90234375,"speaker":"A"},{"text":"they","start":803970,"end":804330,"confidence":0.99902344,"speaker":"A"},{"text":"provide","start":804330,"end":804690,"confidence":1,"speaker":"A"},{"text":"actually","start":804690,"end":805050,"confidence":0.9980469,"speaker":"A"},{"text":"a","start":805050,"end":805290,"confidence":0.91259766,"speaker":"A"},{"text":"REST","start":805290,"end":805490,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":805490,"end":806090,"confidence":0.95166016,"speaker":"A"},{"text":"for","start":806090,"end":806450,"confidence":0.9946289,"speaker":"A"},{"text":"calls","start":806450,"end":806930,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":806930,"end":807170,"confidence":0.9970703,"speaker":"A"},{"text":"CloudKit","start":807170,"end":807880,"confidence":0.9848633,"speaker":"A"},{"text":"using","start":808910,"end":809150,"confidence":0.95654297,"speaker":"A"},{"text":"the,","start":809310,"end":809710,"confidence":0.98828125,"speaker":"A"},{"text":"if","start":809950,"end":810230,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":810230,"end":810350,"confidence":1,"speaker":"A"},{"text":"go","start":810350,"end":810430,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":810430,"end":810550,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":810550,"end":810670,"confidence":0.9995117,"speaker":"A"},{"text":"documentation,","start":810670,"end":811350,"confidence":0.99902344,"speaker":"A"},{"text":"I'll","start":811350,"end":811670,"confidence":0.99820966,"speaker":"A"},{"text":"provide","start":811670,"end":811910,"confidence":0.99658203,"speaker":"A"},{"text":"a","start":811910,"end":812110,"confidence":0.9067383,"speaker":"A"},{"text":"link","start":812110,"end":812350,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":812350,"end":812550,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":812550,"end":812830,"confidence":0.8276367,"speaker":"A"},{"text":"CloudKit","start":812910,"end":813590,"confidence":0.87280273,"speaker":"A"},{"text":"Web","start":813590,"end":813830,"confidence":0.99658203,"speaker":"A"},{"text":"Services","start":813830,"end":814110,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":815310,"end":815710,"confidence":0.99902344,"speaker":"A"},{"text":"provides","start":816510,"end":816990,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":816990,"end":817070,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":817070,"end":817190,"confidence":1,"speaker":"A"},{"text":"of","start":817190,"end":817310,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":817310,"end":817430,"confidence":0.9980469,"speaker":"A"},{"text":"documentation","start":817430,"end":818070,"confidence":0.9998047,"speaker":"A"},{"text":"for","start":818070,"end":818270,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":818270,"end":818390,"confidence":0.99902344,"speaker":"A"},{"text":"we'll","start":818390,"end":818630,"confidence":0.8699544,"speaker":"A"},{"text":"be","start":818630,"end":818790,"confidence":1,"speaker":"A"},{"text":"talking","start":818790,"end":819030,"confidence":0.97631836,"speaker":"A"},{"text":"about","start":819030,"end":819230,"confidence":0.9995117,"speaker":"A"},{"text":"today.","start":819230,"end":819550,"confidence":0.99902344,"speaker":"A"},{"text":"A","start":820910,"end":821150,"confidence":0.99658203,"speaker":"A"},{"text":"lot","start":821150,"end":821270,"confidence":1,"speaker":"A"},{"text":"of","start":821270,"end":821430,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":821430,"end":821590,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":821590,"end":821790,"confidence":0.99853516,"speaker":"A"},{"text":"abstracted","start":821790,"end":822390,"confidence":0.88964844,"speaker":"A"},{"text":"out","start":822390,"end":822550,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":822550,"end":822670,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":822670,"end":822750,"confidence":0.9995117,"speaker":"A"},{"text":"JavaScript","start":822750,"end":823350,"confidence":0.99698895,"speaker":"A"},{"text":"library.","start":823350,"end":823790,"confidence":0.9916992,"speaker":"A"},{"text":"So","start":823870,"end":824109,"confidence":0.9838867,"speaker":"A"},{"text":"if","start":824109,"end":824230,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":824230,"end":824350,"confidence":1,"speaker":"A"},{"text":"want","start":824350,"end":824510,"confidence":0.95166016,"speaker":"A"},{"text":"to","start":824510,"end":824670,"confidence":0.9980469,"speaker":"A"},{"text":"do","start":824670,"end":824790,"confidence":0.9995117,"speaker":"A"},{"text":"stuff","start":824790,"end":824990,"confidence":1,"speaker":"A"},{"text":"on","start":824990,"end":825110,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":825110,"end":825270,"confidence":0.98828125,"speaker":"A"},{"text":"website,","start":825270,"end":825550,"confidence":0.99609375,"speaker":"A"},{"text":"they","start":826430,"end":826790,"confidence":0.9995117,"speaker":"A"},{"text":"provide","start":826790,"end":827150,"confidence":1,"speaker":"A"},{"text":"a","start":827230,"end":827630,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":827790,"end":828590,"confidence":0.99438477,"speaker":"A"},{"text":"JavaScript","start":828590,"end":829390,"confidence":0.9239909,"speaker":"A"},{"text":"library","start":830270,"end":830830,"confidence":0.9996745,"speaker":"A"},{"text":"for","start":830830,"end":831110,"confidence":0.99853516,"speaker":"A"},{"text":"that.","start":831110,"end":831470,"confidence":0.99609375,"speaker":"A"},{"text":"Sorry,","start":833150,"end":833710,"confidence":0.8925781,"speaker":"A"},{"text":"just","start":836190,"end":836310,"confidence":0.93847656,"speaker":"A"},{"text":"going","start":836310,"end":836510,"confidence":0.9814453,"speaker":"A"},{"text":"into","start":836510,"end":836790,"confidence":0.9121094,"speaker":"A"},{"text":"do","start":836790,"end":837030,"confidence":0.99560547,"speaker":"A"},{"text":"not","start":837030,"end":837230,"confidence":0.99902344,"speaker":"A"},{"text":"disturb","start":837230,"end":837870,"confidence":0.87369794,"speaker":"A"},{"text":"mode.","start":838670,"end":839230,"confidence":0.73999023,"speaker":"A"},{"text":"They","start":847950,"end":848270,"confidence":0.9404297,"speaker":"A"},{"text":"even","start":848270,"end":848590,"confidence":0.7373047,"speaker":"A"},{"text":"in","start":848750,"end":849030,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":849030,"end":849270,"confidence":0.99902344,"speaker":"A"},{"text":"web","start":849270,"end":849710,"confidence":0.9995117,"speaker":"A"},{"text":"references","start":849790,"end":850429,"confidence":0.9367676,"speaker":"A"},{"text":"documentation","start":850430,"end":851070,"confidence":0.97734374,"speaker":"A"},{"text":"they","start":851070,"end":851270,"confidence":0.9980469,"speaker":"A"},{"text":"provide","start":851270,"end":851510,"confidence":1,"speaker":"A"},{"text":"a","start":851510,"end":851710,"confidence":0.8413086,"speaker":"A"},{"text":"composing","start":851710,"end":852150,"confidence":0.92008466,"speaker":"A"},{"text":"web","start":852150,"end":852390,"confidence":0.998291,"speaker":"A"},{"text":"service","start":852390,"end":852630,"confidence":0.99902344,"speaker":"A"},{"text":"request","start":852630,"end":853150,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":853470,"end":853750,"confidence":0.9970703,"speaker":"A"},{"text":"all","start":853750,"end":853910,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":853910,"end":854110,"confidence":0.99902344,"speaker":"A"},{"text":"instructions","start":854110,"end":854670,"confidence":0.9996745,"speaker":"A"},{"text":"about","start":854670,"end":854910,"confidence":1,"speaker":"A"},{"text":"how","start":854910,"end":855070,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":855070,"end":855190,"confidence":1,"speaker":"A"},{"text":"go","start":855190,"end":855310,"confidence":1,"speaker":"A"},{"text":"ahead","start":855310,"end":855470,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":855470,"end":855670,"confidence":1,"speaker":"A"},{"text":"do","start":855670,"end":855830,"confidence":1,"speaker":"A"},{"text":"that.","start":855830,"end":856110,"confidence":1,"speaker":"A"},{"text":"So","start":857470,"end":857870,"confidence":0.98876953,"speaker":"A"},{"text":"man,","start":858270,"end":858590,"confidence":0.9482422,"speaker":"A"},{"text":"was","start":858590,"end":858790,"confidence":0.99853516,"speaker":"A"},{"text":"it","start":858790,"end":858950,"confidence":0.9277344,"speaker":"A"},{"text":"like","start":858950,"end":859110,"confidence":0.9941406,"speaker":"A"},{"text":"half","start":859110,"end":859310,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":859310,"end":859470,"confidence":0.99902344,"speaker":"A"},{"text":"decade","start":859470,"end":859790,"confidence":0.99975586,"speaker":"A"},{"text":"ago","start":859790,"end":860110,"confidence":1,"speaker":"A"},{"text":"that","start":860880,"end":861120,"confidence":0.97216797,"speaker":"A"},{"text":"I","start":861280,"end":861680,"confidence":0.97314453,"speaker":"A"},{"text":"built","start":862960,"end":863320,"confidence":0.99153644,"speaker":"A"},{"text":"Heart","start":863320,"end":863520,"confidence":0.8129883,"speaker":"A"},{"text":"Twitch","start":863520,"end":864000,"confidence":0.98999023,"speaker":"A"},{"text":"and","start":864480,"end":864880,"confidence":0.9814453,"speaker":"A"},{"text":"at","start":865360,"end":865640,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":865640,"end":865840,"confidence":0.99853516,"speaker":"A"},{"text":"time","start":865840,"end":866080,"confidence":1,"speaker":"A"},{"text":"I","start":866080,"end":866280,"confidence":1,"speaker":"A"},{"text":"don't","start":866280,"end":866520,"confidence":0.99934894,"speaker":"A"},{"text":"think","start":866520,"end":866720,"confidence":1,"speaker":"A"},{"text":"there","start":866720,"end":866960,"confidence":0.99365234,"speaker":"A"},{"text":"was","start":866960,"end":867280,"confidence":0.9995117,"speaker":"A"},{"text":"anything,","start":867440,"end":868080,"confidence":0.99975586,"speaker":"A"},{"text":"there","start":870080,"end":870360,"confidence":0.99658203,"speaker":"A"},{"text":"was","start":870360,"end":870560,"confidence":0.99902344,"speaker":"A"},{"text":"anything","start":870560,"end":870960,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":870960,"end":871200,"confidence":0.99902344,"speaker":"A"},{"text":"sign","start":871200,"end":871440,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":871440,"end":871640,"confidence":0.9819336,"speaker":"A"},{"text":"with","start":871640,"end":871800,"confidence":1,"speaker":"A"},{"text":"Apple","start":871800,"end":872160,"confidence":0.9995117,"speaker":"A"},{"text":"even.","start":872160,"end":872480,"confidence":0.9970703,"speaker":"A"},{"text":"And","start":872880,"end":873280,"confidence":0.97265625,"speaker":"A"},{"text":"like","start":873520,"end":873840,"confidence":0.9399414,"speaker":"A"},{"text":"I","start":873840,"end":874160,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":874160,"end":874560,"confidence":0.99902344,"speaker":"A"},{"text":"didn't","start":875120,"end":875640,"confidence":0.99348956,"speaker":"A"},{"text":"want","start":875640,"end":875920,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":876880,"end":877280,"confidence":0.9794922,"speaker":"A"},{"text":"to","start":878160,"end":878480,"confidence":0.98291016,"speaker":"A"},{"text":"explain","start":878480,"end":878760,"confidence":0.99853516,"speaker":"A"},{"text":"how","start":878760,"end":878920,"confidence":0.9995117,"speaker":"A"},{"text":"harshwitch","start":878920,"end":879520,"confidence":0.62939453,"speaker":"A"},{"text":"works","start":879520,"end":879800,"confidence":0.99975586,"speaker":"A"},{"text":"is","start":879800,"end":879960,"confidence":0.91064453,"speaker":"A"},{"text":"you","start":879960,"end":880120,"confidence":0.99853516,"speaker":"A"},{"text":"have","start":880120,"end":880320,"confidence":1,"speaker":"A"},{"text":"like","start":880320,"end":880520,"confidence":0.9902344,"speaker":"A"},{"text":"a","start":880520,"end":880680,"confidence":0.9995117,"speaker":"A"},{"text":"watch","start":880680,"end":880960,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":881360,"end":881720,"confidence":0.6225586,"speaker":"A"},{"text":"it","start":881720,"end":881960,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":881960,"end":882200,"confidence":0.9995117,"speaker":"A"},{"text":"send","start":882200,"end":882600,"confidence":0.9291992,"speaker":"A"},{"text":"the","start":882600,"end":882840,"confidence":0.9995117,"speaker":"A"},{"text":"heart","start":882840,"end":883040,"confidence":0.9995117,"speaker":"A"},{"text":"rate","start":883040,"end":883280,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":883280,"end":883480,"confidence":1,"speaker":"A"},{"text":"the","start":883480,"end":883640,"confidence":1,"speaker":"A"},{"text":"server","start":883640,"end":884160,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":885360,"end":885640,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":885640,"end":885920,"confidence":0.9926758,"speaker":"A"},{"text":"the","start":887020,"end":887180,"confidence":0.99658203,"speaker":"A"},{"text":"server","start":887180,"end":887580,"confidence":1,"speaker":"A"},{"text":"will","start":887580,"end":887780,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":887780,"end":888020,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":888020,"end":888260,"confidence":1,"speaker":"A"},{"text":"a","start":888260,"end":888420,"confidence":0.99853516,"speaker":"A"},{"text":"web","start":888420,"end":888660,"confidence":0.7871094,"speaker":"A"},{"text":"socket","start":888660,"end":889180,"confidence":0.9996745,"speaker":"A"},{"text":"to","start":889180,"end":889540,"confidence":0.9995117,"speaker":"A"},{"text":"push","start":889540,"end":889860,"confidence":1,"speaker":"A"},{"text":"it","start":889860,"end":890020,"confidence":0.99902344,"speaker":"A"},{"text":"out","start":890020,"end":890180,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":890180,"end":890340,"confidence":1,"speaker":"A"},{"text":"a","start":890340,"end":890500,"confidence":0.99853516,"speaker":"A"},{"text":"web","start":890500,"end":890740,"confidence":0.99975586,"speaker":"A"},{"text":"page.","start":890740,"end":891100,"confidence":0.84643555,"speaker":"A"},{"text":"And","start":892060,"end":892340,"confidence":0.97558594,"speaker":"A"},{"text":"then","start":892340,"end":892620,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":892620,"end":892900,"confidence":0.99902344,"speaker":"A"},{"text":"would","start":892900,"end":893180,"confidence":0.9838867,"speaker":"A"},{"text":"point","start":893500,"end":893900,"confidence":0.9926758,"speaker":"A"},{"text":"OBS","start":893980,"end":894380,"confidence":0.9897461,"speaker":"A"},{"text":"or","start":894540,"end":894780,"confidence":0.99072266,"speaker":"A"},{"text":"some","start":894780,"end":894900,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":894900,"end":895100,"confidence":0.9926758,"speaker":"A"},{"text":"of","start":895100,"end":895260,"confidence":0.53027344,"speaker":"A"},{"text":"streaming","start":895260,"end":895700,"confidence":0.91813153,"speaker":"A"},{"text":"software","start":895700,"end":896020,"confidence":0.9998779,"speaker":"A"},{"text":"to","start":896020,"end":896180,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":896180,"end":896340,"confidence":1,"speaker":"A"},{"text":"URL","start":896340,"end":896860,"confidence":0.99487305,"speaker":"A"},{"text":"or","start":896860,"end":897060,"confidence":0.9980469,"speaker":"A"},{"text":"to","start":897060,"end":897220,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":897220,"end":897340,"confidence":1,"speaker":"A"},{"text":"browser","start":897340,"end":897700,"confidence":0.9983724,"speaker":"A"},{"text":"window","start":897700,"end":898060,"confidence":1,"speaker":"A"},{"text":"and","start":898060,"end":898220,"confidence":0.99072266,"speaker":"A"},{"text":"then","start":898220,"end":898380,"confidence":0.8310547,"speaker":"A"},{"text":"that","start":898380,"end":898580,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":898580,"end":898740,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":898740,"end":898860,"confidence":1,"speaker":"A"},{"text":"can","start":898860,"end":898980,"confidence":0.9995117,"speaker":"A"},{"text":"stream","start":898980,"end":899260,"confidence":0.99609375,"speaker":"A"},{"text":"your","start":899260,"end":899460,"confidence":0.99853516,"speaker":"A"},{"text":"heart","start":899460,"end":899660,"confidence":0.9980469,"speaker":"A"},{"text":"rate.","start":899660,"end":899940,"confidence":0.9951172,"speaker":"A"},{"text":"That's","start":899940,"end":900220,"confidence":0.9996745,"speaker":"A"},{"text":"how","start":900220,"end":900300,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":900300,"end":900420,"confidence":0.99853516,"speaker":"A"},{"text":"works.","start":900420,"end":900860,"confidence":0.9946289,"speaker":"A"},{"text":"And","start":901500,"end":901780,"confidence":0.9711914,"speaker":"A"},{"text":"what","start":901780,"end":901940,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":901940,"end":902100,"confidence":1,"speaker":"A"},{"text":"really","start":902100,"end":902339,"confidence":0.9995117,"speaker":"A"},{"text":"didn't","start":902339,"end":902659,"confidence":0.9980469,"speaker":"A"},{"text":"want","start":902659,"end":902900,"confidence":1,"speaker":"A"},{"text":"is","start":902900,"end":903180,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":903180,"end":903500,"confidence":0.9711914,"speaker":"A"},{"text":"difficult","start":903500,"end":903980,"confidence":0.9699707,"speaker":"A"},{"text":"way","start":903980,"end":904180,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":904180,"end":904380,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":904380,"end":904580,"confidence":0.8876953,"speaker":"A"},{"text":"user","start":904580,"end":904900,"confidence":1,"speaker":"A"},{"text":"to","start":904900,"end":905100,"confidence":0.9995117,"speaker":"A"},{"text":"log","start":905100,"end":905420,"confidence":1,"speaker":"A"},{"text":"in","start":905420,"end":905820,"confidence":0.9838867,"speaker":"A"},{"text":"with","start":906540,"end":906820,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":906820,"end":906980,"confidence":0.7949219,"speaker":"A"},{"text":"username","start":906980,"end":907500,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":907500,"end":907620,"confidence":0.99902344,"speaker":"A"},{"text":"password","start":907620,"end":908020,"confidence":0.90152997,"speaker":"A"},{"text":"on","start":908020,"end":908180,"confidence":0.6225586,"speaker":"A"},{"text":"the","start":908180,"end":908340,"confidence":0.9995117,"speaker":"A"},{"text":"watch","start":908340,"end":908620,"confidence":0.9995117,"speaker":"A"},{"text":"because","start":908620,"end":908900,"confidence":0.72558594,"speaker":"A"},{"text":"we","start":908900,"end":909020,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":909020,"end":909140,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":909140,"end":909300,"confidence":0.9980469,"speaker":"A"},{"text":"typing","start":909300,"end":909620,"confidence":0.8249512,"speaker":"A"},{"text":"on","start":909620,"end":909740,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":909740,"end":909820,"confidence":0.9951172,"speaker":"A"},{"text":"watch","start":909820,"end":910020,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":910020,"end":910380,"confidence":0.84472656,"speaker":"A"},{"text":"hell.","start":910780,"end":911260,"confidence":0.9157715,"speaker":"A"},{"text":"So","start":911900,"end":912300,"confidence":0.9770508,"speaker":"A"},{"text":"my,","start":912460,"end":912860,"confidence":0.70410156,"speaker":"A"},{"text":"my","start":912860,"end":913140,"confidence":0.9995117,"speaker":"A"},{"text":"thought","start":913140,"end":913340,"confidence":0.99902344,"speaker":"A"},{"text":"was","start":913340,"end":913620,"confidence":0.99853516,"speaker":"A"},{"text":"like,","start":913620,"end":913980,"confidence":0.9897461,"speaker":"A"},{"text":"and","start":914320,"end":914480,"confidence":0.6791992,"speaker":"A"},{"text":"I","start":914480,"end":914680,"confidence":1,"speaker":"A"},{"text":"didn't","start":914680,"end":914920,"confidence":0.9996745,"speaker":"A"},{"text":"have","start":914920,"end":915200,"confidence":0.9921875,"speaker":"A"},{"text":"sign","start":915280,"end":915600,"confidence":0.8886719,"speaker":"A"},{"text":"in","start":915600,"end":915800,"confidence":0.59814453,"speaker":"A"},{"text":"with","start":915800,"end":915960,"confidence":1,"speaker":"A"},{"text":"Apple,","start":915960,"end":916280,"confidence":1,"speaker":"A"},{"text":"right?","start":916280,"end":916560,"confidence":0.9970703,"speaker":"A"},{"text":"So","start":917440,"end":917720,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":917720,"end":917880,"confidence":0.99902344,"speaker":"A"},{"text":"thought","start":917880,"end":918080,"confidence":0.9995117,"speaker":"A"},{"text":"was","start":918080,"end":918320,"confidence":0.99902344,"speaker":"A"},{"text":"why","start":918320,"end":918520,"confidence":1,"speaker":"A"},{"text":"don't","start":918520,"end":918720,"confidence":0.9972331,"speaker":"A"},{"text":"we","start":918720,"end":918840,"confidence":1,"speaker":"A"},{"text":"use","start":918840,"end":919000,"confidence":1,"speaker":"A"},{"text":"CloudKit?","start":919000,"end":919680,"confidence":0.9992676,"speaker":"A"},{"text":"Because","start":919840,"end":920120,"confidence":0.98095703,"speaker":"A"},{"text":"you're","start":920120,"end":920320,"confidence":0.9998372,"speaker":"A"},{"text":"already","start":920320,"end":920520,"confidence":1,"speaker":"A"},{"text":"signed","start":920520,"end":920880,"confidence":0.9963379,"speaker":"A"},{"text":"in","start":920880,"end":921000,"confidence":0.71728516,"speaker":"A"},{"text":"a","start":921000,"end":921120,"confidence":0.61376953,"speaker":"A"},{"text":"CloudKit","start":921120,"end":921640,"confidence":0.99658203,"speaker":"A"},{"text":"on","start":921640,"end":921800,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":921800,"end":921960,"confidence":1,"speaker":"A"},{"text":"Watch","start":921960,"end":922240,"confidence":0.99853516,"speaker":"A"},{"text":"with","start":922800,"end":923120,"confidence":0.99853516,"speaker":"A"},{"text":"your,","start":923120,"end":923440,"confidence":0.9980469,"speaker":"A"},{"text":"your","start":923440,"end":923760,"confidence":0.9995117,"speaker":"A"},{"text":"id.","start":923760,"end":924080,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":926640,"end":926920,"confidence":0.99316406,"speaker":"A"},{"text":"what","start":926920,"end":927080,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":927080,"end":927320,"confidence":1,"speaker":"A"},{"text":"do","start":927320,"end":927680,"confidence":1,"speaker":"A"},{"text":"is","start":928320,"end":928720,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":929440,"end":929720,"confidence":0.9995117,"speaker":"A"},{"text":"log","start":929720,"end":929920,"confidence":1,"speaker":"A"},{"text":"in","start":929920,"end":930159,"confidence":0.9975586,"speaker":"A"},{"text":"with","start":930159,"end":930359,"confidence":1,"speaker":"A"},{"text":"a","start":930359,"end":930480,"confidence":0.9794922,"speaker":"A"},{"text":"regular","start":930480,"end":930760,"confidence":1,"speaker":"A"},{"text":"like","start":930760,"end":930960,"confidence":0.9975586,"speaker":"A"},{"text":"email","start":930960,"end":931240,"confidence":1,"speaker":"A"},{"text":"address","start":931240,"end":931520,"confidence":1,"speaker":"A"},{"text":"and","start":931520,"end":931760,"confidence":0.6791992,"speaker":"A"},{"text":"password","start":931760,"end":932320,"confidence":0.88378906,"speaker":"A"},{"text":"in","start":933040,"end":933440,"confidence":0.7763672,"speaker":"A"},{"text":"Heart","start":933680,"end":934000,"confidence":0.66796875,"speaker":"A"},{"text":"Twitch","start":934000,"end":934400,"confidence":0.9975586,"speaker":"A"},{"text":"on","start":934400,"end":934560,"confidence":1,"speaker":"A"},{"text":"the","start":934560,"end":934680,"confidence":1,"speaker":"A"},{"text":"website.","start":934680,"end":934960,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":935840,"end":936120,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":936120,"end":936280,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":936280,"end":936520,"confidence":0.8927409,"speaker":"A"},{"text":"a","start":936520,"end":936640,"confidence":0.9995117,"speaker":"A"},{"text":"little,","start":936640,"end":936840,"confidence":1,"speaker":"A"},{"text":"there's","start":936840,"end":937200,"confidence":0.9996745,"speaker":"A"},{"text":"a","start":937200,"end":937360,"confidence":0.9995117,"speaker":"A"},{"text":"site,","start":937360,"end":937640,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":937640,"end":937960,"confidence":0.99886066,"speaker":"A"},{"text":"a","start":937960,"end":938160,"confidence":0.9995117,"speaker":"A"},{"text":"part","start":938160,"end":938360,"confidence":1,"speaker":"A"},{"text":"of","start":938360,"end":938480,"confidence":1,"speaker":"A"},{"text":"the","start":938480,"end":938560,"confidence":1,"speaker":"A"},{"text":"site","start":938560,"end":938720,"confidence":1,"speaker":"A"},{"text":"where","start":938720,"end":938920,"confidence":1,"speaker":"A"},{"text":"you","start":938920,"end":939040,"confidence":1,"speaker":"A"},{"text":"can","start":939040,"end":939280,"confidence":1,"speaker":"A"},{"text":"sign","start":939840,"end":940120,"confidence":1,"speaker":"A"},{"text":"into","start":940120,"end":940360,"confidence":0.8144531,"speaker":"A"},{"text":"CloudKit","start":940360,"end":941120,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":942180,"end":942300,"confidence":0.94628906,"speaker":"A"},{"text":"then","start":942300,"end":942500,"confidence":0.99902344,"speaker":"A"},{"text":"from","start":942500,"end":942740,"confidence":1,"speaker":"A"},{"text":"there","start":942740,"end":943060,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":944180,"end":944540,"confidence":0.9526367,"speaker":"A"},{"text":"can,","start":944540,"end":944900,"confidence":1,"speaker":"A"},{"text":"because,","start":945860,"end":946260,"confidence":0.8623047,"speaker":"A"},{"text":"because","start":946260,"end":946540,"confidence":0.99853516,"speaker":"A"},{"text":"of","start":946540,"end":946700,"confidence":0.9897461,"speaker":"A"},{"text":"the","start":946700,"end":946820,"confidence":0.9980469,"speaker":"A"},{"text":"CloudKit","start":946820,"end":947340,"confidence":0.99438477,"speaker":"A"},{"text":"JavaScript","start":947340,"end":947980,"confidence":0.9984538,"speaker":"A"},{"text":"library,","start":947980,"end":948380,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":948380,"end":948540,"confidence":0.95751953,"speaker":"A"},{"text":"can","start":948540,"end":948660,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":948660,"end":948820,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":948820,"end":948980,"confidence":0.9951172,"speaker":"A"},{"text":"can","start":948980,"end":949100,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":949100,"end":949300,"confidence":0.9951172,"speaker":"A"},{"text":"pull","start":949300,"end":949620,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":949620,"end":949940,"confidence":0.9140625,"speaker":"A"},{"text":"all","start":952260,"end":952580,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":952580,"end":952780,"confidence":0.99902344,"speaker":"A"},{"text":"devices","start":952780,"end":953220,"confidence":0.9992676,"speaker":"A"},{"text":"because","start":953220,"end":953540,"confidence":0.99902344,"speaker":"A"},{"text":"when","start":953540,"end":953740,"confidence":1,"speaker":"A"},{"text":"you","start":953740,"end":953900,"confidence":0.9995117,"speaker":"A"},{"text":"first","start":953900,"end":954100,"confidence":1,"speaker":"A"},{"text":"launch","start":954100,"end":954340,"confidence":1,"speaker":"A"},{"text":"the","start":954340,"end":954540,"confidence":0.9746094,"speaker":"A"},{"text":"app","start":954540,"end":954700,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":954700,"end":954820,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":954820,"end":954900,"confidence":0.9995117,"speaker":"A"},{"text":"Watch,","start":954900,"end":955100,"confidence":0.9897461,"speaker":"A"},{"text":"it","start":955100,"end":955340,"confidence":0.93408203,"speaker":"A"},{"text":"adds","start":955340,"end":955580,"confidence":0.9987793,"speaker":"A"},{"text":"your","start":955580,"end":955740,"confidence":0.9980469,"speaker":"A"},{"text":"watch","start":955740,"end":956020,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":956340,"end":956620,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":956620,"end":956740,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":956740,"end":957300,"confidence":0.99609375,"speaker":"A"},{"text":"database.","start":957300,"end":957940,"confidence":0.9998372,"speaker":"A"},{"text":"And","start":958260,"end":958540,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":958540,"end":958660,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":958660,"end":958780,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":958780,"end":958940,"confidence":0.66503906,"speaker":"A"},{"text":"pull","start":958940,"end":959140,"confidence":1,"speaker":"A"},{"text":"that","start":959140,"end":959300,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":959300,"end":959540,"confidence":0.9980469,"speaker":"A"},{"text":"and","start":959540,"end":959740,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":959740,"end":959900,"confidence":0.9970703,"speaker":"A"},{"text":"add","start":959900,"end":960060,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":960060,"end":960220,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":960220,"end":960380,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":960380,"end":960540,"confidence":0.9995117,"speaker":"A"},{"text":"postgres","start":960540,"end":961140,"confidence":0.98583984,"speaker":"A"},{"text":"database.","start":961140,"end":961700,"confidence":1,"speaker":"A"},{"text":"So","start":961700,"end":961980,"confidence":0.99658203,"speaker":"A"},{"text":"then","start":961980,"end":962260,"confidence":0.9970703,"speaker":"A"},{"text":"there","start":962260,"end":962540,"confidence":1,"speaker":"A"},{"text":"is","start":962540,"end":962740,"confidence":0.9995117,"speaker":"A"},{"text":"no","start":962740,"end":962940,"confidence":0.9995117,"speaker":"A"},{"text":"need","start":962940,"end":963140,"confidence":1,"speaker":"A"},{"text":"for","start":963140,"end":963380,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":963380,"end":964180,"confidence":0.9998779,"speaker":"A"},{"text":"because","start":964740,"end":965140,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":965220,"end":965500,"confidence":0.9980469,"speaker":"A"},{"text":"already","start":965500,"end":965700,"confidence":1,"speaker":"A"},{"text":"have","start":965700,"end":965900,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":965900,"end":966060,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit,","start":966060,"end":966740,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":967720,"end":967880,"confidence":0.9663086,"speaker":"A"},{"text":"device","start":967880,"end":968280,"confidence":0.9992676,"speaker":"A"},{"text":"added","start":968280,"end":968600,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":969000,"end":969280,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":969280,"end":969480,"confidence":0.9926758,"speaker":"A"},{"text":"postgres","start":969480,"end":970000,"confidence":0.89941406,"speaker":"A"},{"text":"database.","start":970000,"end":970400,"confidence":0.9998372,"speaker":"A"},{"text":"So","start":970400,"end":970520,"confidence":0.8930664,"speaker":"A"},{"text":"it's","start":970520,"end":970720,"confidence":0.87093097,"speaker":"A"},{"text":"kind","start":970720,"end":970840,"confidence":0.93603516,"speaker":"A"},{"text":"of","start":970840,"end":970960,"confidence":0.859375,"speaker":"A"},{"text":"like","start":970960,"end":971120,"confidence":0.9736328,"speaker":"A"},{"text":"knows,","start":971120,"end":971440,"confidence":0.94555664,"speaker":"A"},{"text":"oh","start":971440,"end":971680,"confidence":0.97143555,"speaker":"A"},{"text":"yeah,","start":971680,"end":972040,"confidence":0.9983724,"speaker":"A"},{"text":"this","start":972200,"end":972480,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":972480,"end":972720,"confidence":0.99902344,"speaker":"A"},{"text":"Leo's","start":972720,"end":973280,"confidence":0.9902344,"speaker":"A"},{"text":"watch,","start":973280,"end":973560,"confidence":0.99853516,"speaker":"A"},{"text":"he","start":974040,"end":974320,"confidence":0.99902344,"speaker":"A"},{"text":"doesn't","start":974320,"end":974520,"confidence":0.9996745,"speaker":"A"},{"text":"need","start":974520,"end":974640,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":974640,"end":974840,"confidence":0.9863281,"speaker":"A"},{"text":"authenticate.","start":974840,"end":975520,"confidence":0.9996338,"speaker":"A"},{"text":"And","start":975520,"end":975760,"confidence":0.9116211,"speaker":"A"},{"text":"that","start":975760,"end":975920,"confidence":0.99365234,"speaker":"A"},{"text":"way","start":975920,"end":976120,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":976120,"end":976320,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":976320,"end":976520,"confidence":0.9995117,"speaker":"A"},{"text":"link","start":976520,"end":976800,"confidence":0.99975586,"speaker":"A"},{"text":"devices","start":976800,"end":977240,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":977240,"end":977520,"confidence":0.9614258,"speaker":"A"},{"text":"accounts","start":977520,"end":978200,"confidence":0.9980469,"speaker":"A"},{"text":"without","start":978280,"end":978680,"confidence":0.9995117,"speaker":"A"},{"text":"having","start":978680,"end":978960,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":978960,"end":979120,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":979120,"end":979280,"confidence":0.9995117,"speaker":"A"},{"text":"any","start":979280,"end":979440,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":979440,"end":979640,"confidence":0.99625653,"speaker":"A"},{"text":"of","start":979640,"end":979760,"confidence":0.9951172,"speaker":"A"},{"text":"login","start":979760,"end":980200,"confidence":0.984375,"speaker":"A"},{"text":"process.","start":980200,"end":980520,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":981080,"end":981360,"confidence":0.9008789,"speaker":"A"},{"text":"so","start":981360,"end":981600,"confidence":0.59228516,"speaker":"A"},{"text":"this","start":981600,"end":981840,"confidence":0.9995117,"speaker":"A"},{"text":"was","start":981840,"end":982000,"confidence":0.9951172,"speaker":"A"},{"text":"my","start":982000,"end":982200,"confidence":0.99902344,"speaker":"A"},{"text":"use","start":982200,"end":982440,"confidence":0.9916992,"speaker":"A"},{"text":"case","start":982440,"end":982760,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":982919,"end":983320,"confidence":0.9995117,"speaker":"A"},{"text":"doing","start":983800,"end":984200,"confidence":0.99902344,"speaker":"A"},{"text":"server","start":985160,"end":985680,"confidence":0.71899414,"speaker":"A"},{"text":"side.","start":985680,"end":985960,"confidence":0.9086914,"speaker":"A"},{"text":"Essentially","start":986040,"end":986680,"confidence":0.9888916,"speaker":"A"},{"text":"CloudKit","start":987000,"end":987720,"confidence":0.87207,"speaker":"A"},{"text":"was","start":987720,"end":988000,"confidence":0.98583984,"speaker":"A"},{"text":"I","start":988000,"end":988240,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":988240,"end":988400,"confidence":0.99365234,"speaker":"A"},{"text":"call","start":988400,"end":988600,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":988600,"end":988800,"confidence":0.99853516,"speaker":"A"},{"text":"CloudKit","start":988800,"end":989360,"confidence":0.9609375,"speaker":"A"},{"text":"web","start":989360,"end":989560,"confidence":0.9902344,"speaker":"A"},{"text":"server","start":989560,"end":990040,"confidence":0.99902344,"speaker":"A"},{"text":"based","start":993410,"end":993610,"confidence":0.98876953,"speaker":"A"},{"text":"on","start":993610,"end":993850,"confidence":1,"speaker":"A"},{"text":"that","start":993850,"end":994050,"confidence":0.9995117,"speaker":"A"},{"text":"person's","start":994050,"end":994690,"confidence":0.99690753,"speaker":"A"},{"text":"web","start":995570,"end":995970,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":995970,"end":996610,"confidence":0.9998779,"speaker":"A"},{"text":"token,","start":996610,"end":996970,"confidence":0.9998372,"speaker":"A"},{"text":"which","start":996970,"end":997130,"confidence":0.9995117,"speaker":"A"},{"text":"we'll","start":997130,"end":997330,"confidence":0.9316406,"speaker":"A"},{"text":"get","start":997330,"end":997490,"confidence":0.99902344,"speaker":"A"},{"text":"all","start":997490,"end":997730,"confidence":0.74365234,"speaker":"A"},{"text":"into","start":997730,"end":998010,"confidence":0.99072266,"speaker":"A"},{"text":"later.","start":998010,"end":998370,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":998530,"end":998850,"confidence":0.5698242,"speaker":"A"},{"text":"then","start":998850,"end":999050,"confidence":0.91748047,"speaker":"A"},{"text":"pull","start":999050,"end":999250,"confidence":0.99975586,"speaker":"A"},{"text":"that","start":999250,"end":999410,"confidence":0.9980469,"speaker":"A"},{"text":"information","start":999410,"end":999730,"confidence":0.9995117,"speaker":"A"},{"text":"in.","start":999970,"end":1000370,"confidence":0.9824219,"speaker":"A"},{"text":"So.","start":1002050,"end":1002450,"confidence":0.8515625,"speaker":"A"},{"text":"Cool.","start":1007250,"end":1007730,"confidence":0.9333496,"speaker":"A"},{"text":"Just","start":1010770,"end":1011050,"confidence":0.99121094,"speaker":"A"},{"text":"checking","start":1011050,"end":1011370,"confidence":0.9980469,"speaker":"A"},{"text":"if","start":1011370,"end":1011530,"confidence":0.99853516,"speaker":"A"},{"text":"anybody's","start":1011530,"end":1012050,"confidence":0.94539386,"speaker":"A"},{"text":"having","start":1012050,"end":1012210,"confidence":0.9995117,"speaker":"A"},{"text":"issues.","start":1012210,"end":1012530,"confidence":0.99853516,"speaker":"A"},{"text":"It","start":1012530,"end":1012770,"confidence":0.5439453,"speaker":"A"},{"text":"doesn't","start":1012770,"end":1013050,"confidence":0.9983724,"speaker":"A"},{"text":"look","start":1013050,"end":1013210,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":1013210,"end":1013370,"confidence":0.99853516,"speaker":"A"},{"text":"it.","start":1013370,"end":1013650,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":1013650,"end":1014050,"confidence":0.8925781,"speaker":"A"},{"text":"that's","start":1014690,"end":1015050,"confidence":0.98014325,"speaker":"A"},{"text":"good","start":1015050,"end":1015210,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1015210,"end":1015370,"confidence":0.9980469,"speaker":"A"},{"text":"know.","start":1015370,"end":1015650,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":1017170,"end":1017410,"confidence":0.9707031,"speaker":"A"},{"text":"that","start":1017410,"end":1017530,"confidence":0.98779297,"speaker":"A"},{"text":"was","start":1017530,"end":1017690,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1017690,"end":1017850,"confidence":0.9995117,"speaker":"A"},{"text":"private","start":1017850,"end":1018090,"confidence":0.9995117,"speaker":"A"},{"text":"database","start":1018090,"end":1018690,"confidence":0.9998372,"speaker":"A"},{"text":"piece,","start":1018690,"end":1019090,"confidence":0.99576825,"speaker":"A"},{"text":"but","start":1019950,"end":1020070,"confidence":0.97558594,"speaker":"A"},{"text":"I","start":1020070,"end":1020230,"confidence":0.99853516,"speaker":"A"},{"text":"actually","start":1020230,"end":1020470,"confidence":0.9970703,"speaker":"A"},{"text":"think","start":1020470,"end":1020790,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1020790,"end":1021030,"confidence":0.9921875,"speaker":"A"},{"text":"much","start":1021030,"end":1021230,"confidence":0.9946289,"speaker":"A"},{"text":"more","start":1021230,"end":1021470,"confidence":1,"speaker":"A"},{"text":"useful","start":1021470,"end":1021910,"confidence":0.99975586,"speaker":"A"},{"text":"case","start":1021910,"end":1022270,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":1022670,"end":1022990,"confidence":1,"speaker":"A"},{"text":"be","start":1022990,"end":1023270,"confidence":1,"speaker":"A"},{"text":"the","start":1023270,"end":1023510,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1023510,"end":1023750,"confidence":0.9995117,"speaker":"A"},{"text":"database","start":1023750,"end":1024430,"confidence":0.99934894,"speaker":"A"},{"text":"because","start":1024990,"end":1025390,"confidence":0.9946289,"speaker":"A"},{"text":"the","start":1026830,"end":1027150,"confidence":0.99853516,"speaker":"A"},{"text":"idea","start":1027150,"end":1027550,"confidence":0.9758301,"speaker":"A"},{"text":"would","start":1027550,"end":1027750,"confidence":0.99658203,"speaker":"A"},{"text":"be","start":1027750,"end":1027950,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":1027950,"end":1028150,"confidence":0.93359375,"speaker":"A"},{"text":"that","start":1028150,"end":1028310,"confidence":0.99853516,"speaker":"A"},{"text":"you'd","start":1028310,"end":1028630,"confidence":0.96516925,"speaker":"A"},{"text":"have","start":1028630,"end":1028910,"confidence":1,"speaker":"A"},{"text":"some","start":1029710,"end":1029990,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":1029990,"end":1030230,"confidence":0.99609375,"speaker":"A"},{"text":"of","start":1030230,"end":1030390,"confidence":0.9975586,"speaker":"A"},{"text":"app","start":1030390,"end":1030670,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1030670,"end":1030950,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":1030950,"end":1031150,"confidence":0.9970703,"speaker":"A"},{"text":"use","start":1031150,"end":1031470,"confidence":0.99902344,"speaker":"A"},{"text":"central","start":1031550,"end":1031950,"confidence":0.9995117,"speaker":"A"},{"text":"repository","start":1031950,"end":1032790,"confidence":0.99694824,"speaker":"A"},{"text":"of","start":1032790,"end":1032990,"confidence":0.99853516,"speaker":"A"},{"text":"data","start":1032990,"end":1033310,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1035470,"end":1035790,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":1035790,"end":1035950,"confidence":0.63134766,"speaker":"A"},{"text":"can","start":1035950,"end":1036070,"confidence":0.9980469,"speaker":"A"},{"text":"pull","start":1036070,"end":1036390,"confidence":0.99975586,"speaker":"A"},{"text":"information","start":1036390,"end":1036750,"confidence":1,"speaker":"A"},{"text":"from.","start":1036990,"end":1037390,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":1037790,"end":1038110,"confidence":0.91259766,"speaker":"A"},{"text":"I'm","start":1038110,"end":1038390,"confidence":0.99104816,"speaker":"A"},{"text":"looking","start":1038390,"end":1038550,"confidence":0.9902344,"speaker":"A"},{"text":"at","start":1038550,"end":1038710,"confidence":0.99902344,"speaker":"A"},{"text":"both","start":1038710,"end":1038870,"confidence":1,"speaker":"A"},{"text":"of","start":1038870,"end":1039030,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":1039030,"end":1039310,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":1039310,"end":1039710,"confidence":0.99902344,"speaker":"A"},{"text":"Bushel","start":1039950,"end":1040590,"confidence":0.90722656,"speaker":"A"},{"text":"and","start":1040590,"end":1040790,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":1040790,"end":1040950,"confidence":0.9584961,"speaker":"A"},{"text":"an","start":1040950,"end":1041190,"confidence":0.98291016,"speaker":"A"},{"text":"RSS","start":1041190,"end":1041670,"confidence":0.9987793,"speaker":"A"},{"text":"reader","start":1041670,"end":1042070,"confidence":0.9975586,"speaker":"A"},{"text":"I'm","start":1042070,"end":1042270,"confidence":0.93929034,"speaker":"A"},{"text":"building","start":1042270,"end":1042430,"confidence":0.9995117,"speaker":"A"},{"text":"called","start":1042430,"end":1042630,"confidence":0.9584961,"speaker":"A"},{"text":"Celestra","start":1042630,"end":1043310,"confidence":0.9358724,"speaker":"A"},{"text":"with","start":1044190,"end":1044510,"confidence":0.98535156,"speaker":"A"},{"text":"Bushel.","start":1044510,"end":1045150,"confidence":0.9350586,"speaker":"A"},{"text":"The.","start":1046199,"end":1046439,"confidence":0.84375,"speaker":"A"},{"text":"The","start":1046679,"end":1046959,"confidence":0.9980469,"speaker":"A"},{"text":"way","start":1046959,"end":1047119,"confidence":1,"speaker":"A"},{"text":"it's","start":1047119,"end":1047319,"confidence":0.9996745,"speaker":"A"},{"text":"built","start":1047319,"end":1047559,"confidence":0.8929036,"speaker":"A"},{"text":"right","start":1047559,"end":1047759,"confidence":0.9995117,"speaker":"A"},{"text":"now","start":1047759,"end":1047959,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1047959,"end":1048199,"confidence":0.9667969,"speaker":"A"},{"text":"I","start":1048199,"end":1048359,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":1048359,"end":1048479,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":1048479,"end":1048679,"confidence":0.9995117,"speaker":"A"},{"text":"concept","start":1048679,"end":1049079,"confidence":0.9786784,"speaker":"A"},{"text":"of","start":1049079,"end":1049319,"confidence":0.9995117,"speaker":"A"},{"text":"hubs","start":1049319,"end":1049719,"confidence":0.9838867,"speaker":"A"},{"text":"and","start":1050679,"end":1051079,"confidence":0.96240234,"speaker":"A"},{"text":"you","start":1051159,"end":1051439,"confidence":1,"speaker":"A"},{"text":"can","start":1051439,"end":1051599,"confidence":0.99902344,"speaker":"A"},{"text":"plug","start":1051599,"end":1051799,"confidence":1,"speaker":"A"},{"text":"in","start":1051799,"end":1051919,"confidence":0.9951172,"speaker":"A"},{"text":"a","start":1051919,"end":1052079,"confidence":0.99072266,"speaker":"A"},{"text":"URL","start":1052079,"end":1052639,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":1052639,"end":1052839,"confidence":0.9628906,"speaker":"A"},{"text":"that","start":1052839,"end":1052959,"confidence":0.99902344,"speaker":"A"},{"text":"URL","start":1052959,"end":1053439,"confidence":0.9367676,"speaker":"A"},{"text":"would","start":1053439,"end":1053719,"confidence":0.99658203,"speaker":"A"},{"text":"provide","start":1053719,"end":1054039,"confidence":1,"speaker":"A"},{"text":"or","start":1054039,"end":1054399,"confidence":0.99902344,"speaker":"A"},{"text":"some","start":1054399,"end":1054679,"confidence":0.97216797,"speaker":"A"},{"text":"sort","start":1054679,"end":1054919,"confidence":0.9941406,"speaker":"A"},{"text":"of","start":1054919,"end":1055079,"confidence":0.99902344,"speaker":"A"},{"text":"service.","start":1055079,"end":1055399,"confidence":0.99902344,"speaker":"A"},{"text":"That","start":1055959,"end":1056359,"confidence":0.9980469,"speaker":"A"},{"text":"service","start":1056599,"end":1056999,"confidence":0.9980469,"speaker":"A"},{"text":"would","start":1056999,"end":1057279,"confidence":0.9941406,"speaker":"A"},{"text":"then","start":1057279,"end":1057479,"confidence":0.9916992,"speaker":"A"},{"text":"provide","start":1057479,"end":1057799,"confidence":1,"speaker":"A"},{"text":"the","start":1058359,"end":1058639,"confidence":0.9995117,"speaker":"A"},{"text":"Entire","start":1058639,"end":1058999,"confidence":0.99975586,"speaker":"A"},{"text":"List","start":1058999,"end":1059279,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1059279,"end":1059639,"confidence":0.99853516,"speaker":"A"},{"text":"macOS","start":1059719,"end":1060439,"confidence":0.76636,"speaker":"A"},{"text":"restore","start":1060439,"end":1060839,"confidence":0.98168945,"speaker":"A"},{"text":"images","start":1060839,"end":1061278,"confidence":0.9987793,"speaker":"A"},{"text":"that","start":1061278,"end":1061479,"confidence":0.9995117,"speaker":"A"},{"text":"are","start":1061479,"end":1061638,"confidence":0.9995117,"speaker":"A"},{"text":"available.","start":1061638,"end":1061959,"confidence":0.9995117,"speaker":"A"},{"text":"But","start":1064119,"end":1064399,"confidence":0.9941406,"speaker":"A"},{"text":"then","start":1064399,"end":1064559,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1064559,"end":1064719,"confidence":0.9995117,"speaker":"A"},{"text":"realized","start":1064719,"end":1065079,"confidence":0.9863281,"speaker":"A"},{"text":"like","start":1065079,"end":1065319,"confidence":0.90283203,"speaker":"A"},{"text":"really","start":1065319,"end":1065559,"confidence":0.9970703,"speaker":"A"},{"text":"there's","start":1065559,"end":1065839,"confidence":0.9889323,"speaker":"A"},{"text":"only","start":1065839,"end":1065999,"confidence":0.9995117,"speaker":"A"},{"text":"one","start":1065999,"end":1066199,"confidence":0.9995117,"speaker":"A"},{"text":"location","start":1066199,"end":1066679,"confidence":1,"speaker":"A"},{"text":"for","start":1066679,"end":1066919,"confidence":0.9995117,"speaker":"A"},{"text":"those","start":1066919,"end":1067239,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1067319,"end":1067719,"confidence":0.98876953,"speaker":"A"},{"text":"each","start":1067719,"end":1068079,"confidence":0.9824219,"speaker":"A"},{"text":"service","start":1068079,"end":1068399,"confidence":0.9951172,"speaker":"A"},{"text":"is","start":1068399,"end":1068639,"confidence":0.99853516,"speaker":"A"},{"text":"just","start":1068639,"end":1068799,"confidence":0.99609375,"speaker":"A"},{"text":"going","start":1068799,"end":1068919,"confidence":0.8798828,"speaker":"A"},{"text":"to","start":1068919,"end":1068999,"confidence":0.99902344,"speaker":"A"},{"text":"be","start":1068999,"end":1069079,"confidence":0.99853516,"speaker":"A"},{"text":"using","start":1069079,"end":1069319,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1069319,"end":1069559,"confidence":0.9995117,"speaker":"A"},{"text":"same","start":1069559,"end":1069719,"confidence":0.9995117,"speaker":"A"},{"text":"URLs","start":1069719,"end":1070359,"confidence":0.92261,"speaker":"A"},{"text":"anyway.","start":1070359,"end":1070839,"confidence":0.99731445,"speaker":"A"},{"text":"So","start":1071970,"end":1072050,"confidence":0.92822266,"speaker":"A"},{"text":"if","start":1072050,"end":1072170,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1072170,"end":1072330,"confidence":0.9995117,"speaker":"A"},{"text":"had","start":1072330,"end":1072570,"confidence":0.9975586,"speaker":"A"},{"text":"one","start":1072570,"end":1072850,"confidence":0.9995117,"speaker":"A"},{"text":"central","start":1072850,"end":1073170,"confidence":1,"speaker":"A"},{"text":"repository","start":1073250,"end":1074050,"confidence":0.9127197,"speaker":"A"},{"text":"or","start":1074050,"end":1074250,"confidence":0.99853516,"speaker":"A"},{"text":"one","start":1074250,"end":1074450,"confidence":0.9970703,"speaker":"A"},{"text":"central","start":1074450,"end":1074770,"confidence":1,"speaker":"A"},{"text":"database","start":1074770,"end":1075490,"confidence":1,"speaker":"A"},{"text":"because","start":1076850,"end":1077170,"confidence":0.99365234,"speaker":"A"},{"text":"they","start":1077170,"end":1077370,"confidence":0.9975586,"speaker":"A"},{"text":"all","start":1077370,"end":1077530,"confidence":0.99902344,"speaker":"A"},{"text":"pull","start":1077530,"end":1077770,"confidence":0.99975586,"speaker":"A"},{"text":"from","start":1077770,"end":1077970,"confidence":0.9995117,"speaker":"A"},{"text":"Apple,","start":1077970,"end":1078450,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1078690,"end":1079010,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1079010,"end":1079210,"confidence":0.99365234,"speaker":"A"},{"text":"then","start":1079210,"end":1079490,"confidence":0.98828125,"speaker":"A"},{"text":"parse","start":1079650,"end":1080250,"confidence":0.8129883,"speaker":"A"},{"text":"the","start":1080250,"end":1080490,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1080490,"end":1080850,"confidence":0.99975586,"speaker":"A"},{"text":"for","start":1081090,"end":1081410,"confidence":0.59033203,"speaker":"A"},{"text":"those","start":1081410,"end":1081690,"confidence":0.99902344,"speaker":"A"},{"text":"restore","start":1081690,"end":1082210,"confidence":0.98779297,"speaker":"A"},{"text":"images","start":1082210,"end":1082690,"confidence":0.99780273,"speaker":"A"},{"text":"and","start":1082690,"end":1082930,"confidence":0.99072266,"speaker":"A"},{"text":"then","start":1082930,"end":1083090,"confidence":0.99658203,"speaker":"A"},{"text":"store","start":1083090,"end":1083370,"confidence":0.9736328,"speaker":"A"},{"text":"them","start":1083370,"end":1083530,"confidence":0.9238281,"speaker":"A"},{"text":"in","start":1083530,"end":1083650,"confidence":0.98779297,"speaker":"A"},{"text":"CloudKit","start":1083650,"end":1084210,"confidence":0.94812,"speaker":"A"},{"text":"and","start":1084210,"end":1084370,"confidence":0.8354492,"speaker":"A"},{"text":"then","start":1084370,"end":1084530,"confidence":0.9873047,"speaker":"A"},{"text":"that","start":1084530,"end":1084770,"confidence":0.9980469,"speaker":"A"},{"text":"way","start":1084770,"end":1085090,"confidence":0.99853516,"speaker":"A"},{"text":"Bushel","start":1085410,"end":1086010,"confidence":0.8808594,"speaker":"A"},{"text":"can","start":1086010,"end":1086170,"confidence":0.9501953,"speaker":"A"},{"text":"then","start":1086170,"end":1086450,"confidence":0.95751953,"speaker":"A"},{"text":"pull","start":1087570,"end":1087930,"confidence":0.9995117,"speaker":"A"},{"text":"those","start":1087930,"end":1088210,"confidence":0.9975586,"speaker":"A"},{"text":"from","start":1088210,"end":1088530,"confidence":1,"speaker":"A"},{"text":"one","start":1088530,"end":1088770,"confidence":0.9995117,"speaker":"A"},{"text":"single","start":1088770,"end":1089090,"confidence":1,"speaker":"A"},{"text":"repository.","start":1089090,"end":1089970,"confidence":0.9998779,"speaker":"A"},{"text":"And","start":1090210,"end":1090490,"confidence":0.86572266,"speaker":"A"},{"text":"all","start":1090490,"end":1090650,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":1090650,"end":1090770,"confidence":0.98291016,"speaker":"A"},{"text":"would","start":1090770,"end":1090930,"confidence":0.98583984,"speaker":"A"},{"text":"have","start":1090930,"end":1091090,"confidence":1,"speaker":"A"},{"text":"to","start":1091090,"end":1091210,"confidence":0.99902344,"speaker":"A"},{"text":"do,","start":1091210,"end":1091450,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1091450,"end":1091770,"confidence":0.64404297,"speaker":"A"},{"text":"what","start":1091770,"end":1092010,"confidence":0.9995117,"speaker":"A"},{"text":"I'm","start":1092010,"end":1092210,"confidence":0.99934894,"speaker":"A"},{"text":"doing","start":1092210,"end":1092410,"confidence":1,"speaker":"A"},{"text":"now","start":1092410,"end":1092690,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":1092690,"end":1092930,"confidence":0.99902344,"speaker":"A"},{"text":"running","start":1092930,"end":1093370,"confidence":0.99121094,"speaker":"A"},{"text":"basically","start":1093370,"end":1093850,"confidence":0.998291,"speaker":"A"},{"text":"a","start":1093850,"end":1094090,"confidence":0.9951172,"speaker":"A"},{"text":"GitHub","start":1094090,"end":1094490,"confidence":0.9991862,"speaker":"A"},{"text":"action","start":1094490,"end":1094690,"confidence":1,"speaker":"A"},{"text":"or","start":1094690,"end":1094850,"confidence":0.98828125,"speaker":"A"},{"text":"you","start":1094850,"end":1094930,"confidence":0.91503906,"speaker":"A"},{"text":"could","start":1094930,"end":1095050,"confidence":0.8876953,"speaker":"A"},{"text":"do","start":1095050,"end":1095210,"confidence":0.99853516,"speaker":"A"},{"text":"like","start":1095210,"end":1095370,"confidence":0.8642578,"speaker":"A"},{"text":"a","start":1095370,"end":1095490,"confidence":0.9868164,"speaker":"A"},{"text":"Cron","start":1095490,"end":1095770,"confidence":0.97875977,"speaker":"A"},{"text":"job","start":1095770,"end":1096050,"confidence":1,"speaker":"A"},{"text":"where","start":1096450,"end":1096850,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":1096850,"end":1097130,"confidence":0.99560547,"speaker":"A"},{"text":"would","start":1097130,"end":1097290,"confidence":1,"speaker":"A"},{"text":"run","start":1097290,"end":1097450,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":1097450,"end":1097610,"confidence":0.9824219,"speaker":"A"},{"text":"Ubuntu,","start":1097610,"end":1098090,"confidence":0.8498047,"speaker":"A"},{"text":"wouldn't","start":1098090,"end":1098370,"confidence":0.9715576,"speaker":"A"},{"text":"even","start":1098370,"end":1098490,"confidence":0.99853516,"speaker":"A"},{"text":"need","start":1098490,"end":1098650,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1098650,"end":1098810,"confidence":0.99853516,"speaker":"A"},{"text":"Mac","start":1098810,"end":1099090,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":1099090,"end":1099290,"confidence":0.96240234,"speaker":"A"},{"text":"it","start":1099290,"end":1099450,"confidence":0.99853516,"speaker":"A"},{"text":"would","start":1099450,"end":1099730,"confidence":0.9995117,"speaker":"A"},{"text":"download","start":1099890,"end":1100490,"confidence":1,"speaker":"A"},{"text":"and","start":1100490,"end":1100730,"confidence":0.59228516,"speaker":"A"},{"text":"scrape","start":1100730,"end":1101130,"confidence":0.8902588,"speaker":"A"},{"text":"the","start":1101130,"end":1101290,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1101290,"end":1101530,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":1101530,"end":1101770,"confidence":0.9970703,"speaker":"A"},{"text":"restore","start":1101770,"end":1102250,"confidence":0.9777832,"speaker":"A"},{"text":"images","start":1102250,"end":1102650,"confidence":0.99731445,"speaker":"A"},{"text":"and","start":1102650,"end":1103000,"confidence":0.52197266,"speaker":"A"},{"text":"storm","start":1103070,"end":1103350,"confidence":0.92749023,"speaker":"A"},{"text":"in","start":1103350,"end":1103470,"confidence":0.9951172,"speaker":"A"},{"text":"the","start":1103470,"end":1103590,"confidence":0.99902344,"speaker":"A"},{"text":"public","start":1103590,"end":1103790,"confidence":1,"speaker":"A"},{"text":"database.","start":1103790,"end":1104430,"confidence":0.99820966,"speaker":"A"},{"text":"It's","start":1106350,"end":1106710,"confidence":0.9967448,"speaker":"A"},{"text":"the","start":1106710,"end":1106830,"confidence":0.9995117,"speaker":"A"},{"text":"same","start":1106830,"end":1106950,"confidence":1,"speaker":"A"},{"text":"idea","start":1106950,"end":1107230,"confidence":0.99902344,"speaker":"A"},{"text":"with","start":1107230,"end":1107350,"confidence":0.98779297,"speaker":"A"},{"text":"Celestra.","start":1107350,"end":1107910,"confidence":0.9313151,"speaker":"A"},{"text":"It's","start":1107910,"end":1108110,"confidence":0.99283856,"speaker":"A"},{"text":"an","start":1108110,"end":1108190,"confidence":0.73876953,"speaker":"A"},{"text":"RSS","start":1108190,"end":1108630,"confidence":0.9946289,"speaker":"A"},{"text":"reader.","start":1108630,"end":1109110,"confidence":0.99902344,"speaker":"A"},{"text":"What","start":1109110,"end":1109270,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1109270,"end":1109430,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1109430,"end":1109630,"confidence":0.9995117,"speaker":"A"},{"text":"took","start":1109630,"end":1109870,"confidence":0.99902344,"speaker":"A"},{"text":"those","start":1109870,"end":1110070,"confidence":0.9946289,"speaker":"A"},{"text":"RSS","start":1110070,"end":1110590,"confidence":0.98535156,"speaker":"A"},{"text":"RSS","start":1112750,"end":1113310,"confidence":0.94921875,"speaker":"A"},{"text":"files","start":1113310,"end":1113670,"confidence":0.95703125,"speaker":"A"},{"text":"in","start":1113670,"end":1113830,"confidence":0.99365234,"speaker":"A"},{"text":"the","start":1113830,"end":1113950,"confidence":1,"speaker":"A"},{"text":"web","start":1113950,"end":1114150,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":1114150,"end":1114350,"confidence":0.8354492,"speaker":"A"},{"text":"just","start":1114350,"end":1114630,"confidence":0.99853516,"speaker":"A"},{"text":"scrape","start":1114630,"end":1115110,"confidence":0.8651123,"speaker":"A"},{"text":"them","start":1115110,"end":1115270,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1115270,"end":1115430,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":1115430,"end":1115630,"confidence":0.9970703,"speaker":"A"},{"text":"store","start":1115630,"end":1115950,"confidence":0.97753906,"speaker":"A"},{"text":"them","start":1115950,"end":1116070,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1116070,"end":1116190,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1116190,"end":1116270,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":1116270,"end":1116830,"confidence":0.9890137,"speaker":"A"},{"text":"database","start":1116830,"end":1117470,"confidence":0.9996745,"speaker":"A"},{"text":"in","start":1118110,"end":1118430,"confidence":0.8745117,"speaker":"A"},{"text":"a","start":1118430,"end":1118590,"confidence":0.99902344,"speaker":"A"},{"text":"public","start":1118590,"end":1118750,"confidence":1,"speaker":"A"},{"text":"database","start":1118750,"end":1119390,"confidence":0.9998372,"speaker":"A"},{"text":"and","start":1119390,"end":1119550,"confidence":0.99316406,"speaker":"A"},{"text":"then","start":1119550,"end":1119710,"confidence":0.9741211,"speaker":"A"},{"text":"that","start":1119710,"end":1119910,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":1119910,"end":1120110,"confidence":1,"speaker":"A"},{"text":"people","start":1120110,"end":1120390,"confidence":1,"speaker":"A"},{"text":"can","start":1120390,"end":1120750,"confidence":0.9995117,"speaker":"A"},{"text":"pull","start":1120750,"end":1121110,"confidence":1,"speaker":"A"},{"text":"that","start":1121110,"end":1121310,"confidence":0.99853516,"speaker":"A"},{"text":"up","start":1121310,"end":1121630,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":1121630,"end":1121910,"confidence":0.9980469,"speaker":"A"},{"text":"through","start":1121910,"end":1122110,"confidence":1,"speaker":"A"},{"text":"CloudKit.","start":1122110,"end":1122910,"confidence":0.845459,"speaker":"A"},{"text":"So","start":1125150,"end":1125550,"confidence":0.9873047,"speaker":"A"},{"text":"the","start":1125630,"end":1125910,"confidence":0.99902344,"speaker":"A"},{"text":"idea","start":1125910,"end":1126270,"confidence":1,"speaker":"A"},{"text":"today","start":1126270,"end":1126550,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1126550,"end":1126790,"confidence":0.9980469,"speaker":"A"},{"text":"we're","start":1126790,"end":1127030,"confidence":0.9991862,"speaker":"A"},{"text":"going","start":1127030,"end":1127150,"confidence":0.88671875,"speaker":"A"},{"text":"to","start":1127150,"end":1127230,"confidence":1,"speaker":"A"},{"text":"talk","start":1127230,"end":1127390,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":1127390,"end":1127710,"confidence":0.9975586,"speaker":"A"},{"text":"how","start":1128030,"end":1128350,"confidence":0.99365234,"speaker":"A"},{"text":"to","start":1128350,"end":1128550,"confidence":0.9707031,"speaker":"A"},{"text":"set","start":1128550,"end":1128750,"confidence":0.99853516,"speaker":"A"},{"text":"something,","start":1128750,"end":1129070,"confidence":0.95947266,"speaker":"A"},{"text":"how","start":1129070,"end":1129430,"confidence":0.9814453,"speaker":"A"},{"text":"I","start":1129430,"end":1129710,"confidence":0.99560547,"speaker":"A"},{"text":"set","start":1129710,"end":1129990,"confidence":0.99658203,"speaker":"A"},{"text":"something","start":1129990,"end":1130310,"confidence":1,"speaker":"A"},{"text":"like","start":1130310,"end":1130550,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":1130550,"end":1130750,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":1130750,"end":1131070,"confidence":0.99560547,"speaker":"A"},{"text":"and","start":1131860,"end":1132100,"confidence":0.9321289,"speaker":"A"},{"text":"how","start":1132100,"end":1132380,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1132380,"end":1132540,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":1132540,"end":1132740,"confidence":0.99560547,"speaker":"A"},{"text":"use","start":1132740,"end":1133060,"confidence":0.9277344,"speaker":"A"},{"text":"use","start":1133300,"end":1133580,"confidence":1,"speaker":"A"},{"text":"my","start":1133580,"end":1133780,"confidence":0.99121094,"speaker":"A"},{"text":"library","start":1133780,"end":1134260,"confidence":0.9998372,"speaker":"A"},{"text":"to","start":1134260,"end":1134460,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":1134460,"end":1134620,"confidence":0.9980469,"speaker":"A"},{"text":"go","start":1134620,"end":1134780,"confidence":0.99902344,"speaker":"A"},{"text":"ahead","start":1134780,"end":1134980,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1134980,"end":1135220,"confidence":0.53125,"speaker":"A"},{"text":"do","start":1135220,"end":1135420,"confidence":1,"speaker":"A"},{"text":"this","start":1135420,"end":1135620,"confidence":1,"speaker":"A"},{"text":"yourself","start":1135620,"end":1136060,"confidence":0.99975586,"speaker":"A"},{"text":"for","start":1136060,"end":1136340,"confidence":0.9995117,"speaker":"A"},{"text":"any","start":1136340,"end":1136660,"confidence":0.9995117,"speaker":"A"},{"text":"sort","start":1136660,"end":1136980,"confidence":0.9975586,"speaker":"A"},{"text":"of","start":1136980,"end":1137100,"confidence":0.9995117,"speaker":"A"},{"text":"work","start":1137100,"end":1137340,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1137340,"end":1137580,"confidence":0.99853516,"speaker":"A"},{"text":"you're","start":1137580,"end":1137780,"confidence":0.99886066,"speaker":"A"},{"text":"going","start":1137780,"end":1137860,"confidence":0.7861328,"speaker":"A"},{"text":"to","start":1137860,"end":1137940,"confidence":0.99853516,"speaker":"A"},{"text":"do","start":1137940,"end":1138060,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1138060,"end":1138260,"confidence":0.9140625,"speaker":"A"},{"text":"where","start":1138260,"end":1138460,"confidence":0.9970703,"speaker":"A"},{"text":"you","start":1138460,"end":1138580,"confidence":1,"speaker":"A"},{"text":"want","start":1138580,"end":1138700,"confidence":0.9140625,"speaker":"A"},{"text":"to","start":1138700,"end":1138860,"confidence":0.9941406,"speaker":"A"},{"text":"use","start":1138860,"end":1139100,"confidence":0.99609375,"speaker":"A"},{"text":"either","start":1139100,"end":1139420,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":1139420,"end":1139580,"confidence":0.9238281,"speaker":"A"},{"text":"public","start":1139580,"end":1139780,"confidence":1,"speaker":"A"},{"text":"or","start":1139780,"end":1140020,"confidence":1,"speaker":"A"},{"text":"private","start":1140020,"end":1140300,"confidence":1,"speaker":"A"},{"text":"database","start":1140300,"end":1140980,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1141220,"end":1141500,"confidence":0.7890625,"speaker":"A"},{"text":"CloudKit.","start":1141500,"end":1142180,"confidence":0.99560547,"speaker":"A"},{"text":"So","start":1143300,"end":1143540,"confidence":0.9873047,"speaker":"A"},{"text":"this","start":1143540,"end":1143660,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1143660,"end":1143820,"confidence":1,"speaker":"A"},{"text":"where","start":1143820,"end":1143980,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1143980,"end":1144140,"confidence":0.97509766,"speaker":"A"},{"text":"introduce","start":1144140,"end":1144580,"confidence":0.96435547,"speaker":"A"},{"text":"myself.","start":1144580,"end":1145060,"confidence":0.99487305,"speaker":"A"},{"text":"So","start":1145940,"end":1146180,"confidence":0.9741211,"speaker":"A"},{"text":"I'm","start":1146180,"end":1146340,"confidence":0.99690753,"speaker":"A"},{"text":"going","start":1146340,"end":1146420,"confidence":0.9428711,"speaker":"A"},{"text":"to","start":1146420,"end":1146500,"confidence":0.99853516,"speaker":"A"},{"text":"talk","start":1146500,"end":1146660,"confidence":0.9995117,"speaker":"A"},{"text":"today","start":1146660,"end":1146860,"confidence":0.99121094,"speaker":"A"},{"text":"about","start":1146860,"end":1147020,"confidence":1,"speaker":"A"},{"text":"building","start":1147020,"end":1147299,"confidence":0.9995117,"speaker":"A"},{"text":"Miskit,","start":1147299,"end":1148020,"confidence":0.82421875,"speaker":"A"},{"text":"which","start":1148260,"end":1148540,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1148540,"end":1148700,"confidence":0.99072266,"speaker":"A"},{"text":"my","start":1148700,"end":1148860,"confidence":0.9995117,"speaker":"A"},{"text":"library","start":1148860,"end":1149300,"confidence":1,"speaker":"A"},{"text":"I","start":1149300,"end":1149500,"confidence":0.99853516,"speaker":"A"},{"text":"built","start":1149500,"end":1149860,"confidence":0.96761066,"speaker":"A"},{"text":"for","start":1150340,"end":1150700,"confidence":0.9921875,"speaker":"A"},{"text":"doing","start":1150700,"end":1151060,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":1151460,"end":1152100,"confidence":0.99609375,"speaker":"A"},{"text":"stuff","start":1152100,"end":1152580,"confidence":0.99886066,"speaker":"A"},{"text":"on","start":1152740,"end":1153020,"confidence":0.94628906,"speaker":"A"},{"text":"the","start":1153020,"end":1153180,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1153180,"end":1153540,"confidence":1,"speaker":"A"},{"text":"or","start":1153540,"end":1153740,"confidence":0.9951172,"speaker":"A"},{"text":"essentially","start":1153740,"end":1154180,"confidence":0.9970703,"speaker":"A"},{"text":"off","start":1154180,"end":1154420,"confidence":0.8652344,"speaker":"A"},{"text":"of,","start":1154420,"end":1154740,"confidence":0.9970703,"speaker":"A"},{"text":"not","start":1155380,"end":1155660,"confidence":0.99853516,"speaker":"A"},{"text":"off","start":1155660,"end":1155860,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1155860,"end":1156100,"confidence":0.9970703,"speaker":"A"},{"text":"Apple","start":1156100,"end":1156500,"confidence":0.99975586,"speaker":"A"},{"text":"platforms.","start":1156500,"end":1157140,"confidence":0.9978841,"speaker":"A"},{"text":"Evan,","start":1159770,"end":1160050,"confidence":0.9189453,"speaker":"A"},{"text":"do","start":1160050,"end":1160170,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1160170,"end":1160250,"confidence":0.9873047,"speaker":"A"},{"text":"have","start":1160250,"end":1160330,"confidence":0.9995117,"speaker":"A"},{"text":"any","start":1160330,"end":1160450,"confidence":0.99902344,"speaker":"A"},{"text":"questions","start":1160450,"end":1160850,"confidence":0.99975586,"speaker":"A"},{"text":"before","start":1160850,"end":1161010,"confidence":1,"speaker":"A"},{"text":"I","start":1161010,"end":1161170,"confidence":0.99853516,"speaker":"A"},{"text":"keep","start":1161170,"end":1161330,"confidence":0.99902344,"speaker":"A"},{"text":"going?","start":1161330,"end":1161610,"confidence":0.99902344,"speaker":"A"},{"text":"No,","start":1162730,"end":1163130,"confidence":0.9770508,"speaker":"B"},{"text":"it's","start":1163370,"end":1163730,"confidence":0.9757487,"speaker":"B"},{"text":"good.","start":1163730,"end":1163970,"confidence":0.6723633,"speaker":"B"},{"text":"Good","start":1163970,"end":1164250,"confidence":1,"speaker":"B"},{"text":"topic","start":1164250,"end":1164610,"confidence":0.9953613,"speaker":"B"},{"text":"though.","start":1164610,"end":1164890,"confidence":0.99072266,"speaker":"B"},{"text":"So","start":1166810,"end":1167090,"confidence":0.9042969,"speaker":"A"},{"text":"like","start":1167090,"end":1167250,"confidence":0.9951172,"speaker":"A"},{"text":"I","start":1167250,"end":1167410,"confidence":1,"speaker":"A"},{"text":"said,","start":1167410,"end":1167610,"confidence":1,"speaker":"A"},{"text":"we","start":1167610,"end":1167810,"confidence":1,"speaker":"A"},{"text":"have","start":1167810,"end":1167970,"confidence":1,"speaker":"A"},{"text":"CloudKit","start":1167970,"end":1168570,"confidence":0.86804,"speaker":"A"},{"text":"Web","start":1168570,"end":1168810,"confidence":0.99853516,"speaker":"A"},{"text":"Services","start":1168810,"end":1169050,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":1170170,"end":1170530,"confidence":0.8461914,"speaker":"A"},{"text":"CloudKit","start":1170530,"end":1171090,"confidence":0.9489746,"speaker":"A"},{"text":"Web","start":1171090,"end":1171330,"confidence":0.9975586,"speaker":"A"},{"text":"Services.","start":1171330,"end":1171610,"confidence":0.99902344,"speaker":"A"},{"text":"We","start":1172330,"end":1172730,"confidence":0.53759766,"speaker":"A"},{"text":"provide","start":1172730,"end":1173090,"confidence":1,"speaker":"A"},{"text":"a","start":1173090,"end":1173329,"confidence":0.96240234,"speaker":"A"},{"text":"lot","start":1173329,"end":1173489,"confidence":1,"speaker":"A"},{"text":"of","start":1173489,"end":1173610,"confidence":0.99853516,"speaker":"A"},{"text":"documentation.","start":1173610,"end":1174210,"confidence":0.99990237,"speaker":"A"},{"text":"We","start":1174210,"end":1174450,"confidence":0.99902344,"speaker":"A"},{"text":"talked","start":1174450,"end":1174650,"confidence":0.9987793,"speaker":"A"},{"text":"about","start":1174650,"end":1174770,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":1174770,"end":1175330,"confidence":0.9980469,"speaker":"A"},{"text":"JS","start":1175330,"end":1175770,"confidence":0.7067871,"speaker":"A"},{"text":"and","start":1175850,"end":1176170,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":1176170,"end":1176370,"confidence":0.9819336,"speaker":"A"},{"text":"instructions","start":1176370,"end":1176890,"confidence":0.9773763,"speaker":"A"},{"text":"on","start":1176890,"end":1177090,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":1177090,"end":1177290,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1177290,"end":1177530,"confidence":0.9995117,"speaker":"A"},{"text":"compose","start":1177530,"end":1177930,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1177930,"end":1178090,"confidence":0.9926758,"speaker":"A"},{"text":"web","start":1178090,"end":1178410,"confidence":0.9980469,"speaker":"A"},{"text":"service","start":1178650,"end":1179050,"confidence":0.9902344,"speaker":"A"},{"text":"request","start":1179050,"end":1179570,"confidence":0.99853516,"speaker":"A"},{"text":"which","start":1179570,"end":1179810,"confidence":0.99902344,"speaker":"A"},{"text":"has","start":1179810,"end":1180090,"confidence":0.9975586,"speaker":"A"},{"text":"everything","start":1180090,"end":1180450,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1180450,"end":1180730,"confidence":0.9980469,"speaker":"A"},{"text":"need","start":1180730,"end":1181050,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":1181210,"end":1181490,"confidence":0.99853516,"speaker":"A"},{"text":"compose","start":1181490,"end":1181810,"confidence":0.99487305,"speaker":"A"},{"text":"one.","start":1181810,"end":1182050,"confidence":0.57421875,"speaker":"A"},{"text":"And","start":1182050,"end":1182370,"confidence":0.81640625,"speaker":"A"},{"text":"back","start":1182370,"end":1182610,"confidence":1,"speaker":"A"},{"text":"in","start":1182610,"end":1182810,"confidence":0.9995117,"speaker":"A"},{"text":"2020","start":1182810,"end":1183370,"confidence":0.9978,"speaker":"A"},{"text":"I","start":1183370,"end":1183610,"confidence":0.9995117,"speaker":"A"},{"text":"did","start":1183610,"end":1183730,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":1183730,"end":1183890,"confidence":0.98535156,"speaker":"A"},{"text":"all","start":1183890,"end":1184090,"confidence":0.99316406,"speaker":"A"},{"text":"manually.","start":1184090,"end":1184570,"confidence":0.9992676,"speaker":"A"},{"text":"The","start":1186600,"end":1186760,"confidence":0.9946289,"speaker":"A"},{"text":"thing","start":1186760,"end":1187000,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1187000,"end":1187240,"confidence":0.99902344,"speaker":"A"},{"text":"at","start":1187240,"end":1187440,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":1187440,"end":1187640,"confidence":0.9995117,"speaker":"A"},{"text":"point,","start":1187640,"end":1187960,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1188600,"end":1188880,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1188880,"end":1189040,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":1189040,"end":1189200,"confidence":0.9995117,"speaker":"A"},{"text":"at","start":1189200,"end":1189440,"confidence":0.9814453,"speaker":"A"},{"text":"right","start":1189440,"end":1189720,"confidence":0.99902344,"speaker":"A"},{"text":"there,","start":1189720,"end":1190040,"confidence":0.99902344,"speaker":"A"},{"text":"actually","start":1191000,"end":1191320,"confidence":0.99316406,"speaker":"A"},{"text":"if","start":1191320,"end":1191480,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1191480,"end":1191560,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":1191560,"end":1191680,"confidence":1,"speaker":"A"},{"text":"at","start":1191680,"end":1191800,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1191800,"end":1191920,"confidence":0.9995117,"speaker":"A"},{"text":"top,","start":1191920,"end":1192120,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1192120,"end":1192280,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1192280,"end":1192400,"confidence":1,"speaker":"A"},{"text":"see","start":1192400,"end":1192600,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":1192600,"end":1192760,"confidence":0.98828125,"speaker":"A"},{"text":"hasn't","start":1192760,"end":1193080,"confidence":0.99768066,"speaker":"A"},{"text":"been","start":1193080,"end":1193200,"confidence":0.9995117,"speaker":"A"},{"text":"updated","start":1193200,"end":1193560,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":1193560,"end":1193800,"confidence":0.96875,"speaker":"A"},{"text":"over","start":1193800,"end":1194120,"confidence":0.99902344,"speaker":"A"},{"text":"10","start":1194200,"end":1194480,"confidence":0.99951,"speaker":"A"},{"text":"years,","start":1194480,"end":1194760,"confidence":0.99902344,"speaker":"A"},{"text":"which","start":1196600,"end":1196880,"confidence":0.9975586,"speaker":"A"},{"text":"is","start":1196880,"end":1197160,"confidence":0.99853516,"speaker":"A"},{"text":"kind","start":1197160,"end":1197440,"confidence":0.88671875,"speaker":"A"},{"text":"of","start":1197440,"end":1197600,"confidence":0.9736328,"speaker":"A"},{"text":"crazy,","start":1197600,"end":1198120,"confidence":0.9996745,"speaker":"A"},{"text":"but","start":1198920,"end":1199200,"confidence":0.99609375,"speaker":"A"},{"text":"it","start":1199200,"end":1199360,"confidence":0.99902344,"speaker":"A"},{"text":"works.","start":1199360,"end":1199800,"confidence":0.99731445,"speaker":"A"},{"text":"And","start":1200999,"end":1201280,"confidence":0.7661133,"speaker":"A"},{"text":"then","start":1201280,"end":1201560,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":1202040,"end":1202440,"confidence":0.9975586,"speaker":"A"},{"text":"got","start":1202840,"end":1203240,"confidence":0.96191406,"speaker":"A"},{"text":"introduced","start":1204200,"end":1204800,"confidence":0.9563802,"speaker":"A"},{"text":"to","start":1204800,"end":1204960,"confidence":0.9355469,"speaker":"A"},{"text":"something","start":1204960,"end":1205200,"confidence":0.9970703,"speaker":"A"},{"text":"back","start":1205200,"end":1205440,"confidence":0.9951172,"speaker":"A"},{"text":"in","start":1205440,"end":1205600,"confidence":0.9897461,"speaker":"A"},{"text":"WWDC","start":1205600,"end":1206520,"confidence":0.7050781,"speaker":"A"},{"text":"I","start":1206520,"end":1206760,"confidence":0.93896484,"speaker":"A"},{"text":"want","start":1206760,"end":1206840,"confidence":0.89404297,"speaker":"A"},{"text":"to","start":1206840,"end":1206920,"confidence":0.9980469,"speaker":"A"},{"text":"say","start":1206920,"end":1207040,"confidence":0.99609375,"speaker":"A"},{"text":"it","start":1207040,"end":1207160,"confidence":0.8076172,"speaker":"A"},{"text":"was","start":1207160,"end":1207400,"confidence":0.79248047,"speaker":"A"},{"text":"23.","start":1207480,"end":1208200,"confidence":0.99805,"speaker":"A"},{"text":"We","start":1210280,"end":1210600,"confidence":0.99853516,"speaker":"A"},{"text":"got","start":1210600,"end":1210840,"confidence":0.96240234,"speaker":"A"},{"text":"introduced","start":1210840,"end":1211360,"confidence":0.9744466,"speaker":"A"},{"text":"to","start":1211360,"end":1211520,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1211520,"end":1211680,"confidence":0.9995117,"speaker":"A"},{"text":"Open","start":1211680,"end":1211920,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1211920,"end":1212440,"confidence":0.97436523,"speaker":"A"},{"text":"generator","start":1212440,"end":1213000,"confidence":0.9851074,"speaker":"A"},{"text":"which","start":1213800,"end":1214000,"confidence":0.99365234,"speaker":"A"},{"text":"is","start":1214000,"end":1214320,"confidence":1,"speaker":"A"},{"text":"really","start":1214320,"end":1214600,"confidence":0.9995117,"speaker":"A"},{"text":"nice","start":1214600,"end":1215000,"confidence":1,"speaker":"A"},{"text":"because","start":1215000,"end":1215400,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":1215960,"end":1216360,"confidence":0.9760742,"speaker":"A"},{"text":"we","start":1216840,"end":1217160,"confidence":0.6513672,"speaker":"A"},{"text":"have,","start":1217160,"end":1217480,"confidence":0.9902344,"speaker":"A"},{"text":"we","start":1217640,"end":1217920,"confidence":0.99609375,"speaker":"A"},{"text":"can","start":1217920,"end":1218080,"confidence":0.99902344,"speaker":"A"},{"text":"generate","start":1218080,"end":1218440,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1218440,"end":1218560,"confidence":0.9975586,"speaker":"A"},{"text":"Swift","start":1218560,"end":1218840,"confidence":0.7780762,"speaker":"A"},{"text":"code","start":1218840,"end":1219120,"confidence":0.96761066,"speaker":"A"},{"text":"if","start":1219120,"end":1219280,"confidence":1,"speaker":"A"},{"text":"we","start":1219280,"end":1219440,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":1219440,"end":1219640,"confidence":0.98779297,"speaker":"A"},{"text":"what","start":1219640,"end":1219840,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1219840,"end":1220080,"confidence":0.9638672,"speaker":"A"},{"text":"Open","start":1220080,"end":1220400,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1220400,"end":1220880,"confidence":0.8979492,"speaker":"A"},{"text":"documentation","start":1220880,"end":1221720,"confidence":0.99970704,"speaker":"A"},{"text":"looks","start":1222200,"end":1222600,"confidence":1,"speaker":"A"},{"text":"like","start":1222600,"end":1222720,"confidence":0.99902344,"speaker":"A"},{"text":"it.","start":1222720,"end":1222880,"confidence":0.7519531,"speaker":"A"},{"text":"And","start":1222880,"end":1223040,"confidence":0.87597656,"speaker":"A"},{"text":"of","start":1223040,"end":1223160,"confidence":0.9980469,"speaker":"A"},{"text":"course","start":1223160,"end":1223280,"confidence":1,"speaker":"A"},{"text":"Apple","start":1223280,"end":1223600,"confidence":0.99975586,"speaker":"A"},{"text":"doesn't","start":1223600,"end":1223840,"confidence":0.99853516,"speaker":"A"},{"text":"provide","start":1223840,"end":1224080,"confidence":1,"speaker":"A"},{"text":"one","start":1224080,"end":1224320,"confidence":0.9926758,"speaker":"A"},{"text":"for","start":1224320,"end":1224480,"confidence":0.99902344,"speaker":"A"},{"text":"CloudKit","start":1224480,"end":1225240,"confidence":0.9314,"speaker":"A"},{"text":"but","start":1225960,"end":1226280,"confidence":0.9951172,"speaker":"A"},{"text":"they","start":1226280,"end":1226480,"confidence":0.88427734,"speaker":"A"},{"text":"did","start":1226480,"end":1226720,"confidence":0.98779297,"speaker":"A"},{"text":"provide","start":1226720,"end":1227040,"confidence":1,"speaker":"A"},{"text":"a","start":1227040,"end":1227280,"confidence":0.9995117,"speaker":"A"},{"text":"pretty","start":1227280,"end":1227520,"confidence":0.9998372,"speaker":"A"},{"text":"big","start":1227520,"end":1227720,"confidence":1,"speaker":"A"},{"text":"piece","start":1227720,"end":1228120,"confidence":0.99869794,"speaker":"A"},{"text":"open.","start":1229240,"end":1229639,"confidence":0.6689453,"speaker":"A"},{"text":"If","start":1229800,"end":1230040,"confidence":0.9873047,"speaker":"A"},{"text":"you","start":1230040,"end":1230120,"confidence":0.77490234,"speaker":"A"},{"text":"ever","start":1230120,"end":1230360,"confidence":0.91748047,"speaker":"A"},{"text":"you","start":1230360,"end":1230640,"confidence":0.7763672,"speaker":"A"},{"text":"looked","start":1230640,"end":1230920,"confidence":0.9987793,"speaker":"A"},{"text":"at","start":1230920,"end":1231000,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1231000,"end":1231120,"confidence":0.99902344,"speaker":"A"},{"text":"Open","start":1231120,"end":1231320,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":1231320,"end":1231760,"confidence":0.9448242,"speaker":"A"},{"text":"generator,","start":1231760,"end":1232160,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":1232160,"end":1232400,"confidence":0.89192706,"speaker":"A"},{"text":"amazing.","start":1232400,"end":1232840,"confidence":0.9998372,"speaker":"A"},{"text":"Takes","start":1232840,"end":1233200,"confidence":0.7607422,"speaker":"A"},{"text":"the","start":1233200,"end":1233320,"confidence":0.46704102,"speaker":"A"},{"text":"Open","start":1233320,"end":1233520,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":1233520,"end":1234080,"confidence":0.9501953,"speaker":"A"},{"text":"gamble","start":1234080,"end":1234640,"confidence":0.7845052,"speaker":"A"},{"text":"file","start":1234640,"end":1235000,"confidence":0.99121094,"speaker":"A"},{"text":"and","start":1235000,"end":1235320,"confidence":0.53125,"speaker":"A"},{"text":"generates","start":1235560,"end":1236160,"confidence":0.99975586,"speaker":"A"},{"text":"all","start":1236160,"end":1236400,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1236400,"end":1236560,"confidence":0.99609375,"speaker":"A"},{"text":"Swift","start":1236560,"end":1236840,"confidence":0.7429199,"speaker":"A"},{"text":"code","start":1236840,"end":1237080,"confidence":0.9991862,"speaker":"A"},{"text":"you","start":1237080,"end":1237240,"confidence":0.99853516,"speaker":"A"},{"text":"need.","start":1237240,"end":1237560,"confidence":1,"speaker":"A"},{"text":"One","start":1237880,"end":1238160,"confidence":0.99560547,"speaker":"A"},{"text":"of","start":1238160,"end":1238320,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1238320,"end":1238440,"confidence":1,"speaker":"A"},{"text":"other","start":1238440,"end":1238600,"confidence":0.99902344,"speaker":"A"},{"text":"issues","start":1238600,"end":1238880,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1238880,"end":1239120,"confidence":0.99902344,"speaker":"A"},{"text":"had","start":1239120,"end":1239280,"confidence":0.99658203,"speaker":"A"},{"text":"with","start":1239280,"end":1239560,"confidence":0.98828125,"speaker":"A"},{"text":"first","start":1240880,"end":1241040,"confidence":0.98339844,"speaker":"A"},{"text":"developing","start":1241040,"end":1241480,"confidence":0.99902344,"speaker":"A"},{"text":"Miskit","start":1241480,"end":1242160,"confidence":0.90844727,"speaker":"A"},{"text":"in","start":1242160,"end":1242440,"confidence":0.99072266,"speaker":"A"},{"text":"2020","start":1242440,"end":1243120,"confidence":0.99658,"speaker":"A"},{"text":"was","start":1243600,"end":1243920,"confidence":0.99609375,"speaker":"A"},{"text":"that","start":1243920,"end":1244160,"confidence":0.9951172,"speaker":"A"},{"text":"there","start":1244160,"end":1244360,"confidence":1,"speaker":"A"},{"text":"was","start":1244360,"end":1244520,"confidence":0.9995117,"speaker":"A"},{"text":"no","start":1244520,"end":1244720,"confidence":1,"speaker":"A"},{"text":"way","start":1244720,"end":1245000,"confidence":1,"speaker":"A"},{"text":"to","start":1245000,"end":1245320,"confidence":0.99658203,"speaker":"A"},{"text":"like","start":1245320,"end":1245680,"confidence":0.99072266,"speaker":"A"},{"text":"there","start":1245840,"end":1246160,"confidence":0.9770508,"speaker":"A"},{"text":"was","start":1246160,"end":1246360,"confidence":0.9941406,"speaker":"A"},{"text":"no","start":1246360,"end":1246520,"confidence":0.95410156,"speaker":"A"},{"text":"abstraction","start":1246520,"end":1247120,"confidence":0.9992676,"speaker":"A"},{"text":"layer","start":1247120,"end":1247520,"confidence":0.99934894,"speaker":"A"},{"text":"which","start":1247520,"end":1247800,"confidence":0.99902344,"speaker":"A"},{"text":"could","start":1247800,"end":1248040,"confidence":0.99316406,"speaker":"A"},{"text":"differentiate","start":1248040,"end":1248640,"confidence":0.9992676,"speaker":"A"},{"text":"between","start":1248640,"end":1248920,"confidence":1,"speaker":"A"},{"text":"doing","start":1248920,"end":1249200,"confidence":0.99902344,"speaker":"A"},{"text":"something","start":1249200,"end":1249440,"confidence":1,"speaker":"A"},{"text":"on","start":1249440,"end":1249640,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":1249640,"end":1249800,"confidence":0.98876953,"speaker":"A"},{"text":"server","start":1249800,"end":1250320,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1250720,"end":1251080,"confidence":0.99902344,"speaker":"A"},{"text":"using","start":1251080,"end":1251440,"confidence":0.9975586,"speaker":"A"},{"text":"regular","start":1251760,"end":1252400,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":1252480,"end":1252880,"confidence":0.9765625,"speaker":"A"},{"text":"URL","start":1253040,"end":1253680,"confidence":0.9951172,"speaker":"A"},{"text":"session","start":1253680,"end":1254040,"confidence":0.9991862,"speaker":"A"},{"text":"which","start":1254040,"end":1254200,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1254200,"end":1254360,"confidence":0.99658203,"speaker":"A"},{"text":"more","start":1254360,"end":1254600,"confidence":1,"speaker":"A"},{"text":"targeted","start":1254600,"end":1255080,"confidence":1,"speaker":"A"},{"text":"towards","start":1255080,"end":1255360,"confidence":0.9992676,"speaker":"A"},{"text":"client","start":1255360,"end":1255719,"confidence":0.9328613,"speaker":"A"},{"text":"side.","start":1255719,"end":1256080,"confidence":0.99853516,"speaker":"A"},{"text":"So","start":1258960,"end":1259360,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":1259440,"end":1259720,"confidence":0.99121094,"speaker":"A"},{"text":"had","start":1259720,"end":1259880,"confidence":0.8510742,"speaker":"A"},{"text":"to","start":1259880,"end":1260000,"confidence":0.97216797,"speaker":"A"},{"text":"build","start":1260000,"end":1260120,"confidence":0.9970703,"speaker":"A"},{"text":"my","start":1260120,"end":1260280,"confidence":0.9995117,"speaker":"A"},{"text":"own","start":1260280,"end":1260440,"confidence":1,"speaker":"A"},{"text":"abstraction","start":1260440,"end":1261000,"confidence":0.90441895,"speaker":"A"},{"text":"for","start":1261000,"end":1261120,"confidence":1,"speaker":"A"},{"text":"that.","start":1261120,"end":1261280,"confidence":1,"speaker":"A"},{"text":"Luckily","start":1261280,"end":1261640,"confidence":0.99641925,"speaker":"A"},{"text":"Open","start":1261640,"end":1261840,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1261840,"end":1262440,"confidence":0.7475586,"speaker":"A"},{"text":"has,","start":1262440,"end":1262800,"confidence":0.99609375,"speaker":"A"},{"text":"there's","start":1264080,"end":1264560,"confidence":0.99820966,"speaker":"A"},{"text":"open","start":1264560,"end":1264880,"confidence":0.87109375,"speaker":"A"},{"text":"API","start":1264960,"end":1265600,"confidence":0.8029785,"speaker":"A"},{"text":"transport","start":1265600,"end":1266240,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":1266240,"end":1266520,"confidence":0.99658203,"speaker":"A"},{"text":"believe,","start":1266520,"end":1266800,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":1266880,"end":1267240,"confidence":0.9995117,"speaker":"A"},{"text":"provides","start":1267240,"end":1267600,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":1267600,"end":1267720,"confidence":0.99121094,"speaker":"A"},{"text":"abstraction","start":1267720,"end":1268400,"confidence":0.98132324,"speaker":"A"},{"text":"layer","start":1268480,"end":1268840,"confidence":0.96940106,"speaker":"A"},{"text":"where","start":1268840,"end":1269000,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1269000,"end":1269120,"confidence":1,"speaker":"A"},{"text":"can","start":1269120,"end":1269240,"confidence":0.9995117,"speaker":"A"},{"text":"then","start":1269240,"end":1269400,"confidence":0.9975586,"speaker":"A"},{"text":"plug","start":1269400,"end":1269640,"confidence":0.9992676,"speaker":"A"},{"text":"in","start":1269640,"end":1269840,"confidence":0.9946289,"speaker":"A"},{"text":"either","start":1269840,"end":1270120,"confidence":0.9980469,"speaker":"A"},{"text":"use","start":1270120,"end":1270400,"confidence":0.99316406,"speaker":"A"},{"text":"Async","start":1270980,"end":1271420,"confidence":0.94433594,"speaker":"A"},{"text":"HTTP","start":1271420,"end":1272100,"confidence":0.9790039,"speaker":"A"},{"text":"client,","start":1272100,"end":1272620,"confidence":0.9975586,"speaker":"A"},{"text":"which","start":1272620,"end":1272900,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1272900,"end":1273140,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1273140,"end":1273420,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1273420,"end":1273900,"confidence":0.99902344,"speaker":"A"},{"text":"way","start":1273900,"end":1274060,"confidence":0.98583984,"speaker":"A"},{"text":"of","start":1274060,"end":1274220,"confidence":1,"speaker":"A"},{"text":"doing","start":1274220,"end":1274380,"confidence":1,"speaker":"A"},{"text":"it,","start":1274380,"end":1274540,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1274540,"end":1274780,"confidence":0.59228516,"speaker":"A"},{"text":"you","start":1274780,"end":1275020,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1275020,"end":1275180,"confidence":0.9995117,"speaker":"A"},{"text":"plug","start":1275180,"end":1275380,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":1275380,"end":1275500,"confidence":0.99658203,"speaker":"A"},{"text":"a","start":1275500,"end":1275660,"confidence":0.99609375,"speaker":"A"},{"text":"URL","start":1275660,"end":1276180,"confidence":0.99853516,"speaker":"A"},{"text":"session","start":1276180,"end":1276660,"confidence":0.87906903,"speaker":"A"},{"text":"transport,","start":1277060,"end":1277780,"confidence":0.99902344,"speaker":"A"},{"text":"which","start":1277860,"end":1278180,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1278180,"end":1278500,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1278500,"end":1278780,"confidence":0.5307617,"speaker":"A"},{"text":"course","start":1278780,"end":1278940,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1278940,"end":1279100,"confidence":0.5600586,"speaker":"A"},{"text":"client","start":1279100,"end":1279380,"confidence":0.99487305,"speaker":"A"},{"text":"way","start":1279380,"end":1279580,"confidence":0.9941406,"speaker":"A"},{"text":"to","start":1279580,"end":1279700,"confidence":0.9995117,"speaker":"A"},{"text":"do,","start":1279700,"end":1279820,"confidence":0.9995117,"speaker":"A"},{"text":"provides","start":1282060,"end":1282420,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1282420,"end":1282540,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":1282540,"end":1282700,"confidence":0.9995117,"speaker":"A"},{"text":"great","start":1282700,"end":1282980,"confidence":0.9995117,"speaker":"A"},{"text":"tutorial.","start":1283060,"end":1283740,"confidence":0.9855957,"speaker":"A"},{"text":"I","start":1283740,"end":1283980,"confidence":0.96777344,"speaker":"A"},{"text":"highly","start":1283980,"end":1284300,"confidence":0.998291,"speaker":"A"},{"text":"recommend","start":1284300,"end":1284620,"confidence":1,"speaker":"A"},{"text":"checking","start":1284620,"end":1284900,"confidence":0.99934894,"speaker":"A"},{"text":"this","start":1284900,"end":1285060,"confidence":0.9951172,"speaker":"A"},{"text":"out","start":1285060,"end":1285380,"confidence":0.9970703,"speaker":"A"},{"text":"as","start":1286579,"end":1286859,"confidence":1,"speaker":"A"},{"text":"well","start":1286859,"end":1287020,"confidence":1,"speaker":"A"},{"text":"as","start":1287020,"end":1287300,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1287380,"end":1287740,"confidence":0.9975586,"speaker":"A"},{"text":"doxy","start":1287740,"end":1288340,"confidence":0.84684247,"speaker":"A"},{"text":"documentation","start":1288340,"end":1289060,"confidence":0.99990237,"speaker":"A"},{"text":"that","start":1289220,"end":1289500,"confidence":0.99853516,"speaker":"A"},{"text":"they","start":1289500,"end":1289700,"confidence":0.9995117,"speaker":"A"},{"text":"provide.","start":1289700,"end":1290020,"confidence":0.9970703,"speaker":"A"},{"text":"So","start":1291860,"end":1292220,"confidence":0.9667969,"speaker":"A"},{"text":"this","start":1292220,"end":1292460,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1292460,"end":1292660,"confidence":0.95654297,"speaker":"A"},{"text":"great.","start":1292660,"end":1292940,"confidence":1,"speaker":"A"},{"text":"But","start":1292940,"end":1293180,"confidence":0.99609375,"speaker":"A"},{"text":"then","start":1293180,"end":1293420,"confidence":0.99853516,"speaker":"A"},{"text":"I'd","start":1293420,"end":1293820,"confidence":0.99625653,"speaker":"A"},{"text":"have","start":1293820,"end":1293980,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1293980,"end":1294100,"confidence":1,"speaker":"A"},{"text":"go","start":1294100,"end":1294220,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":1294220,"end":1294500,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1294660,"end":1294940,"confidence":0.99853516,"speaker":"A"},{"text":"I'd","start":1294940,"end":1295180,"confidence":0.8806966,"speaker":"A"},{"text":"have","start":1295180,"end":1295300,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1295300,"end":1295420,"confidence":0.9995117,"speaker":"A"},{"text":"figure","start":1295420,"end":1295660,"confidence":0.7961426,"speaker":"A"},{"text":"out","start":1295660,"end":1295820,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1295820,"end":1295980,"confidence":0.9970703,"speaker":"A"},{"text":"way","start":1295980,"end":1296260,"confidence":0.99560547,"speaker":"A"},{"text":"to","start":1296900,"end":1297020,"confidence":0.9819336,"speaker":"A"},{"text":"convert","start":1297020,"end":1297300,"confidence":0.9992676,"speaker":"A"},{"text":"all","start":1297300,"end":1297540,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1297540,"end":1297740,"confidence":0.9975586,"speaker":"A"},{"text":"documentation","start":1297740,"end":1298500,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":1298660,"end":1299060,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":1299140,"end":1299420,"confidence":0.99853516,"speaker":"A"},{"text":"open","start":1299420,"end":1299700,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1299700,"end":1300340,"confidence":0.9458008,"speaker":"A"},{"text":"document.","start":1300420,"end":1301140,"confidence":0.9998779,"speaker":"A"},{"text":"I","start":1302420,"end":1302700,"confidence":0.5463867,"speaker":"A"},{"text":"mean,","start":1302700,"end":1302860,"confidence":0.9926758,"speaker":"A"},{"text":"can","start":1302860,"end":1303020,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1303020,"end":1303180,"confidence":0.99902344,"speaker":"A"},{"text":"guess","start":1303180,"end":1303540,"confidence":0.99975586,"speaker":"A"},{"text":"what","start":1303940,"end":1304260,"confidence":0.9995117,"speaker":"A"},{"text":"helped","start":1304260,"end":1304620,"confidence":0.76538086,"speaker":"A"},{"text":"me","start":1304620,"end":1304980,"confidence":0.9926758,"speaker":"A"},{"text":"to","start":1305540,"end":1305820,"confidence":0.9873047,"speaker":"A"},{"text":"get","start":1305820,"end":1306100,"confidence":0.6230469,"speaker":"A"},{"text":"build","start":1306180,"end":1306580,"confidence":0.95996094,"speaker":"A"},{"text":"an","start":1306820,"end":1307100,"confidence":0.9550781,"speaker":"A"},{"text":"open","start":1307100,"end":1307340,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1307340,"end":1307860,"confidence":0.90722656,"speaker":"A"},{"text":"document","start":1307860,"end":1308260,"confidence":0.9959717,"speaker":"A"},{"text":"from","start":1308260,"end":1308460,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":1308460,"end":1308620,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1308620,"end":1308820,"confidence":0.9555664,"speaker":"A"},{"text":"documentation?","start":1308820,"end":1309540,"confidence":0.9988281,"speaker":"A"},{"text":"Some","start":1310340,"end":1310740,"confidence":0.62402344,"speaker":"B"},{"text":"of","start":1311060,"end":1311260,"confidence":0.25683594,"speaker":"B"},{"text":"the","start":1311260,"end":1311300,"confidence":0.56347656,"speaker":"B"},{"text":"tools,","start":1311300,"end":1311620,"confidence":0.72314453,"speaker":"B"},{"text":"some","start":1312659,"end":1312940,"confidence":0.9658203,"speaker":"B"},{"text":"AI","start":1312940,"end":1313260,"confidence":0.9914551,"speaker":"B"},{"text":"tool.","start":1313260,"end":1313540,"confidence":0.9716797,"speaker":"B"},{"text":"Yes.","start":1314500,"end":1314980,"confidence":0.9482422,"speaker":"A"},{"text":"AI","start":1316820,"end":1317340,"confidence":0.91967773,"speaker":"A"},{"text":"came","start":1317340,"end":1317620,"confidence":0.9980469,"speaker":"A"},{"text":"and","start":1317620,"end":1317900,"confidence":0.99853516,"speaker":"A"},{"text":"I'm","start":1317900,"end":1318140,"confidence":0.99934894,"speaker":"A"},{"text":"like,","start":1318140,"end":1318340,"confidence":0.9921875,"speaker":"A"},{"text":"holy","start":1318340,"end":1318620,"confidence":0.82543945,"speaker":"A"},{"text":"crap.","start":1318620,"end":1318980,"confidence":0.86450195,"speaker":"A"},{"text":"Like","start":1319460,"end":1319860,"confidence":0.6220703,"speaker":"A"},{"text":"AI","start":1320180,"end":1320660,"confidence":0.92407227,"speaker":"A"},{"text":"is","start":1320660,"end":1320860,"confidence":0.9946289,"speaker":"A"},{"text":"really","start":1320860,"end":1321020,"confidence":0.99902344,"speaker":"A"},{"text":"good","start":1321020,"end":1321180,"confidence":0.99902344,"speaker":"A"},{"text":"at","start":1321180,"end":1321340,"confidence":0.9995117,"speaker":"A"},{"text":"documenting","start":1321340,"end":1321820,"confidence":0.99990237,"speaker":"A"},{"text":"your","start":1321820,"end":1321980,"confidence":0.99902344,"speaker":"A"},{"text":"code,","start":1321980,"end":1322260,"confidence":0.9998372,"speaker":"A"},{"text":"but","start":1322260,"end":1322460,"confidence":0.96972656,"speaker":"A"},{"text":"it's","start":1322460,"end":1322660,"confidence":0.9749349,"speaker":"A"},{"text":"also","start":1322660,"end":1322820,"confidence":0.9995117,"speaker":"A"},{"text":"pretty","start":1322820,"end":1323060,"confidence":0.9996745,"speaker":"A"},{"text":"darn","start":1323060,"end":1323260,"confidence":0.90804034,"speaker":"A"},{"text":"good","start":1323260,"end":1323420,"confidence":1,"speaker":"A"},{"text":"at","start":1323420,"end":1323700,"confidence":0.9902344,"speaker":"A"},{"text":"taking","start":1324490,"end":1324690,"confidence":0.93066406,"speaker":"A"},{"text":"documentation","start":1324690,"end":1325370,"confidence":0.9998047,"speaker":"A"},{"text":"and","start":1325370,"end":1325570,"confidence":0.99609375,"speaker":"A"},{"text":"building","start":1325570,"end":1325810,"confidence":0.9995117,"speaker":"A"},{"text":"code.","start":1325810,"end":1326250,"confidence":0.8733724,"speaker":"A"},{"text":"So","start":1326890,"end":1327170,"confidence":0.9238281,"speaker":"A"},{"text":"then","start":1327170,"end":1327450,"confidence":0.99658203,"speaker":"A"},{"text":"I","start":1327930,"end":1328250,"confidence":0.9819336,"speaker":"A"},{"text":"would","start":1328250,"end":1328450,"confidence":0.9848633,"speaker":"A"},{"text":"just","start":1328450,"end":1328610,"confidence":0.99902344,"speaker":"A"},{"text":"plug","start":1328610,"end":1328850,"confidence":0.9938965,"speaker":"A"},{"text":"it.","start":1328850,"end":1329050,"confidence":0.8227539,"speaker":"A"},{"text":"I've","start":1329050,"end":1329290,"confidence":0.99397784,"speaker":"A"},{"text":"been","start":1329290,"end":1329410,"confidence":0.9975586,"speaker":"A"},{"text":"plugging","start":1329410,"end":1329730,"confidence":0.95751953,"speaker":"A"},{"text":"in","start":1329730,"end":1329890,"confidence":0.8691406,"speaker":"A"},{"text":"with","start":1329890,"end":1330050,"confidence":0.9995117,"speaker":"A"},{"text":"Claude","start":1330050,"end":1330650,"confidence":0.73999023,"speaker":"A"},{"text":"and","start":1331050,"end":1331330,"confidence":0.9667969,"speaker":"A"},{"text":"it","start":1331330,"end":1331490,"confidence":0.9975586,"speaker":"A"},{"text":"has","start":1331490,"end":1331650,"confidence":1,"speaker":"A"},{"text":"a","start":1331650,"end":1331850,"confidence":0.9995117,"speaker":"A"},{"text":"copy","start":1331850,"end":1332170,"confidence":1,"speaker":"A"},{"text":"of","start":1332170,"end":1332290,"confidence":1,"speaker":"A"},{"text":"all","start":1332290,"end":1332450,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1332450,"end":1332610,"confidence":0.9995117,"speaker":"A"},{"text":"documentation","start":1332610,"end":1333210,"confidence":0.99970704,"speaker":"A"},{"text":"in","start":1333210,"end":1333410,"confidence":0.9277344,"speaker":"A"},{"text":"my","start":1333410,"end":1333570,"confidence":1,"speaker":"A"},{"text":"repo","start":1333570,"end":1334090,"confidence":0.9848633,"speaker":"A"},{"text":"and","start":1334410,"end":1334730,"confidence":0.9682617,"speaker":"A"},{"text":"it","start":1334730,"end":1334930,"confidence":0.8828125,"speaker":"A"},{"text":"can","start":1334930,"end":1335090,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1335090,"end":1335250,"confidence":0.9995117,"speaker":"A"},{"text":"ahead","start":1335250,"end":1335410,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1335410,"end":1335610,"confidence":0.99853516,"speaker":"A"},{"text":"edit","start":1335610,"end":1336090,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1336250,"end":1336490,"confidence":0.9824219,"speaker":"A"},{"text":"open","start":1336490,"end":1336690,"confidence":0.99316406,"speaker":"A"},{"text":"API.","start":1336690,"end":1337210,"confidence":0.9802246,"speaker":"A"},{"text":"It's","start":1337210,"end":1337490,"confidence":0.9817708,"speaker":"A"},{"text":"not","start":1337490,"end":1337690,"confidence":0.99853516,"speaker":"A"},{"text":"perfect","start":1337690,"end":1338010,"confidence":0.97998047,"speaker":"A"},{"text":"by","start":1338010,"end":1338250,"confidence":0.99853516,"speaker":"A"},{"text":"any","start":1338250,"end":1338490,"confidence":1,"speaker":"A"},{"text":"means,","start":1338490,"end":1338810,"confidence":1,"speaker":"A"},{"text":"of","start":1338810,"end":1339090,"confidence":0.99902344,"speaker":"A"},{"text":"course,","start":1339090,"end":1339370,"confidence":1,"speaker":"A"},{"text":"but","start":1339530,"end":1339849,"confidence":0.9970703,"speaker":"A"},{"text":"that's","start":1339849,"end":1340170,"confidence":0.9998372,"speaker":"A"},{"text":"what","start":1340170,"end":1340410,"confidence":0.9980469,"speaker":"A"},{"text":"unit","start":1340410,"end":1340850,"confidence":0.84521484,"speaker":"A"},{"text":"tests","start":1340850,"end":1341210,"confidence":0.9946289,"speaker":"A"},{"text":"are","start":1341210,"end":1341330,"confidence":0.99560547,"speaker":"A"},{"text":"for.","start":1341330,"end":1341610,"confidence":0.99658203,"speaker":"A"},{"text":"And","start":1343850,"end":1344170,"confidence":0.89697266,"speaker":"A"},{"text":"actually","start":1344170,"end":1344410,"confidence":0.99853516,"speaker":"A"},{"text":"having","start":1344410,"end":1344650,"confidence":0.87402344,"speaker":"A"},{"text":"integration","start":1344650,"end":1345210,"confidence":0.9769287,"speaker":"A"},{"text":"tests","start":1345210,"end":1345770,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":1346250,"end":1346530,"confidence":0.99853516,"speaker":"A"},{"text":"order","start":1346530,"end":1346730,"confidence":1,"speaker":"A"},{"text":"to","start":1346730,"end":1346930,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1346930,"end":1347130,"confidence":0.9995117,"speaker":"A"},{"text":"stuff","start":1347130,"end":1347530,"confidence":0.9998372,"speaker":"A"},{"text":"so","start":1347690,"end":1348090,"confidence":0.83496094,"speaker":"A"},{"text":"that.","start":1351460,"end":1351700,"confidence":0.9980469,"speaker":"A"},{"text":"Sorry,","start":1355380,"end":1355740,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1355740,"end":1355860,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1355860,"end":1355980,"confidence":1,"speaker":"A"},{"text":"want","start":1355980,"end":1356140,"confidence":0.99560547,"speaker":"A"},{"text":"to","start":1356140,"end":1356300,"confidence":0.99365234,"speaker":"A"},{"text":"make","start":1356300,"end":1356460,"confidence":1,"speaker":"A"},{"text":"sure","start":1356460,"end":1356740,"confidence":1,"speaker":"A"},{"text":"nothing","start":1360660,"end":1361100,"confidence":0.88623047,"speaker":"A"},{"text":"important.","start":1361100,"end":1361460,"confidence":1,"speaker":"A"},{"text":"I","start":1366900,"end":1367180,"confidence":0.9951172,"speaker":"A"},{"text":"hate","start":1367180,"end":1367460,"confidence":0.9992676,"speaker":"A"},{"text":"teams.","start":1367460,"end":1368020,"confidence":0.9995117,"speaker":"A"},{"text":"Okay,","start":1373060,"end":1373620,"confidence":0.94677734,"speaker":"A"},{"text":"so","start":1374820,"end":1375100,"confidence":0.9980469,"speaker":"A"},{"text":"great.","start":1375100,"end":1375380,"confidence":0.9980469,"speaker":"A"},{"text":"So","start":1375700,"end":1375780,"confidence":0.9995117,"speaker":"A"},{"text":"let's","start":1375780,"end":1375980,"confidence":0.9996745,"speaker":"A"},{"text":"talk","start":1375980,"end":1376140,"confidence":0.9995117,"speaker":"A"},{"text":"about.","start":1376140,"end":1376420,"confidence":0.9980469,"speaker":"A"},{"text":"Sorry,","start":1379700,"end":1380180,"confidence":0.90966797,"speaker":"A"},{"text":"slides","start":1380500,"end":1380900,"confidence":0.76538086,"speaker":"A"},{"text":"are","start":1380900,"end":1381100,"confidence":0.9995117,"speaker":"A"},{"text":"still","start":1381100,"end":1381260,"confidence":1,"speaker":"A"},{"text":"not","start":1381260,"end":1381420,"confidence":1,"speaker":"A"},{"text":"done,","start":1381420,"end":1381620,"confidence":0.9980469,"speaker":"A"},{"text":"but","start":1381620,"end":1381940,"confidence":0.99316406,"speaker":"A"},{"text":"let's","start":1382100,"end":1382460,"confidence":0.9991862,"speaker":"A"},{"text":"talk","start":1382460,"end":1382620,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":1382620,"end":1382900,"confidence":0.9980469,"speaker":"A"},{"text":"authentication","start":1384500,"end":1385380,"confidence":1,"speaker":"A"},{"text":"methods.","start":1385380,"end":1386020,"confidence":0.99975586,"speaker":"A"},{"text":"You","start":1386340,"end":1386620,"confidence":0.9970703,"speaker":"A"},{"text":"can","start":1386620,"end":1386780,"confidence":0.8959961,"speaker":"A"},{"text":"see","start":1386780,"end":1386940,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1386940,"end":1387100,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":1387100,"end":1387380,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1387460,"end":1387740,"confidence":0.99121094,"speaker":"A"},{"text":"logos","start":1387740,"end":1388140,"confidence":0.9980469,"speaker":"A"},{"text":"here,","start":1388140,"end":1388300,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":1388300,"end":1388420,"confidence":1,"speaker":"A"},{"text":"I","start":1388420,"end":1388540,"confidence":0.9995117,"speaker":"A"},{"text":"haven't","start":1388540,"end":1388780,"confidence":0.99975586,"speaker":"A"},{"text":"quite","start":1388780,"end":1389020,"confidence":0.99975586,"speaker":"A"},{"text":"cleaned","start":1389020,"end":1389340,"confidence":0.79541016,"speaker":"A"},{"text":"this","start":1389340,"end":1389540,"confidence":0.9941406,"speaker":"A"},{"text":"up.","start":1389540,"end":1389860,"confidence":0.9970703,"speaker":"A"},{"text":"So","start":1390820,"end":1391220,"confidence":0.9770508,"speaker":"A"},{"text":"there's","start":1391940,"end":1392540,"confidence":0.9983724,"speaker":"A"},{"text":"really","start":1392540,"end":1392900,"confidence":0.99902344,"speaker":"A"},{"text":"two","start":1393780,"end":1394140,"confidence":1,"speaker":"A"},{"text":"and","start":1394140,"end":1394380,"confidence":0.87890625,"speaker":"A"},{"text":"a","start":1394380,"end":1394540,"confidence":0.9667969,"speaker":"A"},{"text":"half","start":1394540,"end":1394820,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":1394820,"end":1395660,"confidence":0.99975586,"speaker":"A"},{"text":"methods","start":1395660,"end":1396140,"confidence":1,"speaker":"A"},{"text":"when","start":1396140,"end":1396300,"confidence":1,"speaker":"A"},{"text":"it","start":1396300,"end":1396420,"confidence":1,"speaker":"A"},{"text":"comes","start":1396420,"end":1396540,"confidence":1,"speaker":"A"},{"text":"to","start":1396540,"end":1396700,"confidence":1,"speaker":"A"},{"text":"CloudKit.","start":1396700,"end":1397380,"confidence":0.9552,"speaker":"A"},{"text":"So","start":1398420,"end":1398820,"confidence":0.9326172,"speaker":"A"},{"text":"here","start":1398900,"end":1399300,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":1399460,"end":1399860,"confidence":0.9658203,"speaker":"A"},{"text":"the","start":1401150,"end":1401270,"confidence":0.95947266,"speaker":"A"},{"text":"miss","start":1401270,"end":1401470,"confidence":0.5654297,"speaker":"A"},{"text":"demo","start":1401470,"end":1401950,"confidence":0.7548828,"speaker":"A"},{"text":"database.","start":1401950,"end":1402630,"confidence":0.9996745,"speaker":"A"},{"text":"You","start":1402630,"end":1402870,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1402870,"end":1403030,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1403030,"end":1403230,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1403230,"end":1403430,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":1403430,"end":1403710,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1404270,"end":1404550,"confidence":0.99560547,"speaker":"A"},{"text":"you","start":1404550,"end":1404710,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1404710,"end":1404870,"confidence":0.99365234,"speaker":"A"},{"text":"go","start":1404870,"end":1404990,"confidence":1,"speaker":"A"},{"text":"to","start":1404990,"end":1405110,"confidence":0.9995117,"speaker":"A"},{"text":"tokens","start":1405110,"end":1405510,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":1405510,"end":1405670,"confidence":0.9892578,"speaker":"A"},{"text":"keys","start":1405670,"end":1406070,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":1406070,"end":1406310,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":1406310,"end":1406470,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1406470,"end":1406630,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1406630,"end":1406790,"confidence":0.9995117,"speaker":"A"},{"text":"give","start":1406790,"end":1406950,"confidence":1,"speaker":"A"},{"text":"you","start":1406950,"end":1407150,"confidence":1,"speaker":"A"},{"text":"access","start":1407150,"end":1407470,"confidence":1,"speaker":"A"},{"text":"to","start":1407470,"end":1407750,"confidence":0.98339844,"speaker":"A"},{"text":"set","start":1407750,"end":1407950,"confidence":0.99658203,"speaker":"A"},{"text":"up","start":1407950,"end":1408270,"confidence":0.7631836,"speaker":"A"},{"text":"either","start":1408510,"end":1408990,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1408990,"end":1409390,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1409870,"end":1410550,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1410550,"end":1410750,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1410750,"end":1410870,"confidence":0.9243164,"speaker":"A"},{"text":"want","start":1410870,"end":1411030,"confidence":0.94921875,"speaker":"A"},{"text":"to","start":1411030,"end":1411150,"confidence":0.9980469,"speaker":"A"},{"text":"do","start":1411150,"end":1411390,"confidence":0.9970703,"speaker":"A"},{"text":"API","start":1411790,"end":1412430,"confidence":0.9926758,"speaker":"A"},{"text":"key","start":1412430,"end":1412830,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1412830,"end":1413110,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1413110,"end":1413470,"confidence":0.8027344,"speaker":"A"},{"text":"token","start":1413470,"end":1414030,"confidence":0.86376953,"speaker":"A"},{"text":"if","start":1414270,"end":1414550,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1414550,"end":1414710,"confidence":1,"speaker":"A"},{"text":"want","start":1414710,"end":1414830,"confidence":0.9394531,"speaker":"A"},{"text":"to","start":1414830,"end":1414910,"confidence":0.99902344,"speaker":"A"},{"text":"do","start":1414910,"end":1415070,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1415070,"end":1415270,"confidence":0.53125,"speaker":"A"},{"text":"private","start":1415270,"end":1415470,"confidence":1,"speaker":"A"},{"text":"database","start":1415470,"end":1416190,"confidence":0.9998372,"speaker":"A"},{"text":"or","start":1416190,"end":1416550,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1416550,"end":1416790,"confidence":0.99853516,"speaker":"A"},{"text":"server","start":1416790,"end":1417109,"confidence":0.9946289,"speaker":"A"},{"text":"to","start":1417109,"end":1417310,"confidence":0.97753906,"speaker":"A"},{"text":"server","start":1417310,"end":1417630,"confidence":0.9992676,"speaker":"A"},{"text":"keyset","start":1417630,"end":1418190,"confidence":0.8388672,"speaker":"A"},{"text":"if","start":1418350,"end":1418630,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1418630,"end":1418750,"confidence":0.99902344,"speaker":"A"},{"text":"want","start":1418750,"end":1418870,"confidence":0.53808594,"speaker":"A"},{"text":"to","start":1418870,"end":1418990,"confidence":0.9951172,"speaker":"A"},{"text":"do","start":1418990,"end":1419150,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1419150,"end":1419310,"confidence":0.8515625,"speaker":"A"},{"text":"public","start":1419310,"end":1419470,"confidence":1,"speaker":"A"},{"text":"database.","start":1419470,"end":1420190,"confidence":0.9996745,"speaker":"A"},{"text":"So","start":1420190,"end":1420430,"confidence":0.98095703,"speaker":"A"},{"text":"let's","start":1420430,"end":1420590,"confidence":0.9998372,"speaker":"A"},{"text":"talk","start":1420590,"end":1420710,"confidence":0.99902344,"speaker":"A"},{"text":"about","start":1420710,"end":1420870,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":1420870,"end":1421030,"confidence":0.9980469,"speaker":"A"},{"text":"API","start":1421030,"end":1421430,"confidence":0.99902344,"speaker":"A"},{"text":"token.","start":1421430,"end":1421950,"confidence":0.9773763,"speaker":"A"},{"text":"Pretty","start":1422510,"end":1422870,"confidence":1,"speaker":"A"},{"text":"simple.","start":1422870,"end":1423310,"confidence":0.83935547,"speaker":"A"},{"text":"You","start":1423470,"end":1423750,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1423750,"end":1423870,"confidence":1,"speaker":"A"},{"text":"go","start":1423870,"end":1423990,"confidence":0.99609375,"speaker":"A"},{"text":"into","start":1423990,"end":1424190,"confidence":0.61572266,"speaker":"A"},{"text":"here,","start":1424190,"end":1424510,"confidence":0.9995117,"speaker":"A"},{"text":"click","start":1424750,"end":1425110,"confidence":0.9987793,"speaker":"A"},{"text":"the","start":1425110,"end":1425270,"confidence":0.9995117,"speaker":"A"},{"text":"plus","start":1425270,"end":1425550,"confidence":0.9980469,"speaker":"A"},{"text":"sign,","start":1425550,"end":1425870,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1426840,"end":1427000,"confidence":0.9980469,"speaker":"A"},{"text":"say","start":1427000,"end":1427200,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1427200,"end":1427320,"confidence":0.91064453,"speaker":"A"},{"text":"name","start":1427320,"end":1427560,"confidence":0.99609375,"speaker":"A"},{"text":"and","start":1428600,"end":1428920,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":1428920,"end":1429120,"confidence":0.99902344,"speaker":"A"},{"text":"say","start":1429120,"end":1429280,"confidence":0.9980469,"speaker":"A"},{"text":"whether","start":1429280,"end":1429440,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1429440,"end":1429600,"confidence":1,"speaker":"A"},{"text":"want","start":1429600,"end":1429720,"confidence":0.99560547,"speaker":"A"},{"text":"to","start":1429720,"end":1429800,"confidence":0.99560547,"speaker":"A"},{"text":"do","start":1429800,"end":1429920,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1429920,"end":1430040,"confidence":0.9995117,"speaker":"A"},{"text":"post","start":1430040,"end":1430240,"confidence":0.9995117,"speaker":"A"},{"text":"message","start":1430240,"end":1430680,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":1430680,"end":1430920,"confidence":0.9995117,"speaker":"A"},{"text":"URL","start":1430920,"end":1431440,"confidence":0.8330078,"speaker":"A"},{"text":"redirect.","start":1431440,"end":1432040,"confidence":1,"speaker":"A"},{"text":"We'll","start":1432280,"end":1432640,"confidence":0.9708659,"speaker":"A"},{"text":"get","start":1432640,"end":1432800,"confidence":1,"speaker":"A"},{"text":"into","start":1432800,"end":1432960,"confidence":1,"speaker":"A"},{"text":"that","start":1432960,"end":1433120,"confidence":1,"speaker":"A"},{"text":"in","start":1433120,"end":1433280,"confidence":0.8725586,"speaker":"A"},{"text":"a","start":1433280,"end":1433400,"confidence":0.99902344,"speaker":"A"},{"text":"little","start":1433400,"end":1433560,"confidence":0.9526367,"speaker":"A"},{"text":"bit","start":1433560,"end":1433760,"confidence":1,"speaker":"A"},{"text":"in","start":1433760,"end":1433920,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":1433920,"end":1434040,"confidence":0.9995117,"speaker":"A"},{"text":"next","start":1434040,"end":1434200,"confidence":0.9995117,"speaker":"A"},{"text":"section.","start":1434200,"end":1434680,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":1435960,"end":1436240,"confidence":0.98828125,"speaker":"A"},{"text":"then","start":1436240,"end":1436480,"confidence":0.89453125,"speaker":"A"},{"text":"whether","start":1436480,"end":1436760,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1436760,"end":1436960,"confidence":1,"speaker":"A"},{"text":"want","start":1436960,"end":1437120,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1437120,"end":1437280,"confidence":1,"speaker":"A"},{"text":"have","start":1437280,"end":1437560,"confidence":1,"speaker":"A"},{"text":"user","start":1437800,"end":1438280,"confidence":0.99902344,"speaker":"A"},{"text":"info","start":1438280,"end":1438760,"confidence":1,"speaker":"A"},{"text":"and","start":1438840,"end":1439240,"confidence":0.99609375,"speaker":"A"},{"text":"you","start":1439400,"end":1439720,"confidence":0.99609375,"speaker":"A"},{"text":"click","start":1439720,"end":1440040,"confidence":0.9995117,"speaker":"A"},{"text":"save","start":1440040,"end":1440360,"confidence":0.9987793,"speaker":"A"},{"text":"and","start":1440360,"end":1440640,"confidence":0.9326172,"speaker":"A"},{"text":"you'll","start":1440640,"end":1440920,"confidence":0.99934894,"speaker":"A"},{"text":"get","start":1440920,"end":1441040,"confidence":1,"speaker":"A"},{"text":"a","start":1441040,"end":1441160,"confidence":0.9995117,"speaker":"A"},{"text":"nice","start":1441160,"end":1441400,"confidence":0.99975586,"speaker":"A"},{"text":"little","start":1441400,"end":1441680,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":1441680,"end":1442280,"confidence":0.86499023,"speaker":"A"},{"text":"token","start":1442519,"end":1442960,"confidence":0.9996745,"speaker":"A"},{"text":"you","start":1442960,"end":1443120,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":1443120,"end":1443280,"confidence":0.9951172,"speaker":"A"},{"text":"use","start":1443280,"end":1443520,"confidence":1,"speaker":"A"},{"text":"in","start":1443520,"end":1443760,"confidence":0.99658203,"speaker":"A"},{"text":"your","start":1443760,"end":1444040,"confidence":0.9848633,"speaker":"A"},{"text":"web","start":1444120,"end":1444600,"confidence":0.99560547,"speaker":"A"},{"text":"your","start":1445240,"end":1445560,"confidence":0.9873047,"speaker":"A"},{"text":"web","start":1445560,"end":1445840,"confidence":0.9987793,"speaker":"A"},{"text":"calls","start":1445840,"end":1446160,"confidence":0.9831543,"speaker":"A"},{"text":"essentially.","start":1446160,"end":1446680,"confidence":0.9581299,"speaker":"A"},{"text":"API","start":1449000,"end":1449560,"confidence":0.8713379,"speaker":"A"},{"text":"doesn't","start":1449560,"end":1449800,"confidence":0.99886066,"speaker":"A"},{"text":"really.","start":1449800,"end":1450000,"confidence":0.9980469,"speaker":"A"},{"text":"The","start":1450000,"end":1450200,"confidence":0.88720703,"speaker":"A"},{"text":"API","start":1450200,"end":1450640,"confidence":0.954834,"speaker":"A"},{"text":"token","start":1450640,"end":1451000,"confidence":0.99934894,"speaker":"A"},{"text":"doesn't","start":1451000,"end":1451200,"confidence":0.9160156,"speaker":"A"},{"text":"really","start":1451200,"end":1451360,"confidence":0.9995117,"speaker":"A"},{"text":"give","start":1451360,"end":1451520,"confidence":1,"speaker":"A"},{"text":"you","start":1451520,"end":1451680,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1451680,"end":1451800,"confidence":0.99853516,"speaker":"A"},{"text":"lot","start":1451800,"end":1452040,"confidence":0.99560547,"speaker":"A"},{"text":"of.","start":1452100,"end":1452260,"confidence":0.515625,"speaker":"A"},{"text":"But","start":1452570,"end":1452690,"confidence":0.98535156,"speaker":"A"},{"text":"what","start":1452690,"end":1452850,"confidence":0.99658203,"speaker":"A"},{"text":"it","start":1452850,"end":1452970,"confidence":0.9902344,"speaker":"A"},{"text":"does","start":1452970,"end":1453130,"confidence":0.9980469,"speaker":"A"},{"text":"give","start":1453130,"end":1453290,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1453290,"end":1453410,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":1453410,"end":1453570,"confidence":0.98779297,"speaker":"A"},{"text":"it","start":1453570,"end":1453690,"confidence":0.9951172,"speaker":"A"},{"text":"gives","start":1453690,"end":1453890,"confidence":0.9733887,"speaker":"A"},{"text":"you","start":1453890,"end":1454010,"confidence":1,"speaker":"A"},{"text":"an","start":1454010,"end":1454170,"confidence":1,"speaker":"A"},{"text":"entry","start":1454170,"end":1454530,"confidence":0.99975586,"speaker":"A"},{"text":"to","start":1454530,"end":1454850,"confidence":1,"speaker":"A"},{"text":"get","start":1454850,"end":1455130,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1455130,"end":1455330,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1455330,"end":1455570,"confidence":1,"speaker":"A"},{"text":"authentication","start":1455570,"end":1456250,"confidence":0.8823242,"speaker":"A"},{"text":"token","start":1456250,"end":1456610,"confidence":0.9998372,"speaker":"A"},{"text":"for","start":1456610,"end":1456770,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1456770,"end":1456930,"confidence":0.48901367,"speaker":"A"},{"text":"user.","start":1456930,"end":1457450,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":1457850,"end":1458130,"confidence":0.99121094,"speaker":"A"},{"text":"basically","start":1458130,"end":1458570,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1458730,"end":1459010,"confidence":1,"speaker":"A"},{"text":"way","start":1459010,"end":1459210,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1459210,"end":1459450,"confidence":1,"speaker":"A"},{"text":"works.","start":1459450,"end":1459930,"confidence":0.99731445,"speaker":"A"},{"text":"So","start":1460970,"end":1461370,"confidence":0.9580078,"speaker":"A"},{"text":"you'll","start":1461450,"end":1461810,"confidence":0.93896484,"speaker":"A"},{"text":"notice","start":1461810,"end":1462170,"confidence":0.99975586,"speaker":"A"},{"text":"here,","start":1462170,"end":1462490,"confidence":0.99902344,"speaker":"A"},{"text":"when","start":1463050,"end":1463370,"confidence":0.9941406,"speaker":"A"},{"text":"we","start":1463370,"end":1463570,"confidence":0.9995117,"speaker":"A"},{"text":"were","start":1463570,"end":1463770,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1463770,"end":1463970,"confidence":1,"speaker":"A"},{"text":"this","start":1463970,"end":1464250,"confidence":0.9995117,"speaker":"A"},{"text":"section,","start":1464330,"end":1464890,"confidence":0.99975586,"speaker":"A"},{"text":"we","start":1467050,"end":1467330,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":1467330,"end":1467490,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1467490,"end":1467690,"confidence":1,"speaker":"A"},{"text":"piece","start":1467690,"end":1467970,"confidence":0.9998372,"speaker":"A"},{"text":"here","start":1467970,"end":1468250,"confidence":0.99902344,"speaker":"A"},{"text":"called","start":1468250,"end":1468569,"confidence":0.99902344,"speaker":"A"},{"text":"Sign","start":1468569,"end":1468770,"confidence":0.9926758,"speaker":"A"},{"text":"in","start":1468770,"end":1468970,"confidence":0.48339844,"speaker":"A"},{"text":"Callback.","start":1468970,"end":1469610,"confidence":0.9967448,"speaker":"A"},{"text":"So","start":1469770,"end":1470170,"confidence":0.9580078,"speaker":"A"},{"text":"you","start":1470330,"end":1470650,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1470650,"end":1470930,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1470930,"end":1471250,"confidence":0.98291016,"speaker":"A"},{"text":"either","start":1471250,"end":1471690,"confidence":1,"speaker":"A"},{"text":"call","start":1471690,"end":1472010,"confidence":0.9741211,"speaker":"A"},{"text":"a","start":1472010,"end":1472210,"confidence":0.96875,"speaker":"A"},{"text":"JavaScript,","start":1472210,"end":1472970,"confidence":0.9967448,"speaker":"A"},{"text":"it's","start":1473370,"end":1473730,"confidence":0.99593097,"speaker":"A"},{"text":"called","start":1473730,"end":1473930,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1473930,"end":1474130,"confidence":0.9794922,"speaker":"A"},{"text":"message","start":1474130,"end":1474530,"confidence":0.9980469,"speaker":"A"},{"text":"event,","start":1474530,"end":1474810,"confidence":0.9897461,"speaker":"A"},{"text":"it","start":1475610,"end":1475890,"confidence":0.9941406,"speaker":"A"},{"text":"will","start":1475890,"end":1476090,"confidence":0.82177734,"speaker":"A"},{"text":"call","start":1476090,"end":1476330,"confidence":0.6923828,"speaker":"A"},{"text":"a","start":1476330,"end":1476530,"confidence":0.90625,"speaker":"A"},{"text":"Message","start":1476530,"end":1476850,"confidence":0.99902344,"speaker":"A"},{"text":"event","start":1476850,"end":1477090,"confidence":0.9897461,"speaker":"A"},{"text":"and","start":1477090,"end":1477450,"confidence":0.97265625,"speaker":"A"},{"text":"a","start":1477450,"end":1477730,"confidence":0.8847656,"speaker":"A"},{"text":"message","start":1477730,"end":1478050,"confidence":0.9987793,"speaker":"A"},{"text":"event","start":1478050,"end":1478250,"confidence":0.9951172,"speaker":"A"},{"text":"will","start":1478250,"end":1478450,"confidence":0.9921875,"speaker":"A"},{"text":"have","start":1478450,"end":1478610,"confidence":1,"speaker":"A"},{"text":"the","start":1478610,"end":1478730,"confidence":0.9975586,"speaker":"A"},{"text":"metadata","start":1478730,"end":1479250,"confidence":0.99886066,"speaker":"A"},{"text":"with","start":1479250,"end":1479410,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1479410,"end":1479530,"confidence":0.99560547,"speaker":"A"},{"text":"web","start":1479530,"end":1479730,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":1479730,"end":1480410,"confidence":0.99975586,"speaker":"A"},{"text":"token","start":1480410,"end":1480770,"confidence":0.9998372,"speaker":"A"},{"text":"of","start":1480770,"end":1480930,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1480930,"end":1481090,"confidence":0.99902344,"speaker":"A"},{"text":"user.","start":1481090,"end":1481530,"confidence":0.99902344,"speaker":"A"},{"text":"Or","start":1482410,"end":1482530,"confidence":0.9902344,"speaker":"A"},{"text":"you","start":1482530,"end":1482650,"confidence":0.7363281,"speaker":"A"},{"text":"could","start":1482650,"end":1482770,"confidence":0.99072266,"speaker":"A"},{"text":"do","start":1482770,"end":1482930,"confidence":0.9946289,"speaker":"A"},{"text":"URL","start":1482930,"end":1483450,"confidence":0.99658203,"speaker":"A"},{"text":"redirect","start":1483450,"end":1484090,"confidence":0.99975586,"speaker":"A"},{"text":"where","start":1484170,"end":1484570,"confidence":0.99121094,"speaker":"A"},{"text":"on","start":1484810,"end":1485210,"confidence":0.8457031,"speaker":"A"},{"text":"authentication","start":1485290,"end":1486050,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1486050,"end":1486290,"confidence":0.9975586,"speaker":"A"},{"text":"user","start":1486290,"end":1486730,"confidence":0.99975586,"speaker":"A"},{"text":"has","start":1486970,"end":1487250,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1487250,"end":1487410,"confidence":0.9975586,"speaker":"A"},{"text":"URL","start":1487410,"end":1487930,"confidence":0.998291,"speaker":"A"},{"text":"and","start":1487930,"end":1488130,"confidence":0.99609375,"speaker":"A"},{"text":"then","start":1488130,"end":1488290,"confidence":0.9560547,"speaker":"A"},{"text":"part","start":1488290,"end":1488450,"confidence":1,"speaker":"A"},{"text":"of","start":1488450,"end":1488570,"confidence":1,"speaker":"A"},{"text":"that","start":1488570,"end":1488690,"confidence":0.9995117,"speaker":"A"},{"text":"URL","start":1488690,"end":1489170,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":1489170,"end":1489330,"confidence":0.99609375,"speaker":"A"},{"text":"then","start":1489330,"end":1489530,"confidence":0.98291016,"speaker":"A"},{"text":"having","start":1489530,"end":1489850,"confidence":0.99658203,"speaker":"A"},{"text":"part","start":1490650,"end":1490930,"confidence":0.9921875,"speaker":"A"},{"text":"of","start":1490930,"end":1491090,"confidence":0.99853516,"speaker":"A"},{"text":"one","start":1491090,"end":1491210,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1491210,"end":1491290,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1491290,"end":1491370,"confidence":1,"speaker":"A"},{"text":"query","start":1491370,"end":1491690,"confidence":0.8486328,"speaker":"A"},{"text":"parameters","start":1491770,"end":1492570,"confidence":0.8824463,"speaker":"A"},{"text":"and","start":1492570,"end":1492850,"confidence":0.9814453,"speaker":"A"},{"text":"we'll","start":1492850,"end":1493050,"confidence":0.99934894,"speaker":"A"},{"text":"get","start":1493050,"end":1493130,"confidence":1,"speaker":"A"},{"text":"into","start":1493130,"end":1493290,"confidence":0.99902344,"speaker":"A"},{"text":"that.","start":1493290,"end":1493610,"confidence":0.9975586,"speaker":"A"},{"text":"We'll","start":1494250,"end":1494570,"confidence":0.89176434,"speaker":"A"},{"text":"then","start":1494570,"end":1494690,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":1494690,"end":1494850,"confidence":1,"speaker":"A"},{"text":"the","start":1494850,"end":1495010,"confidence":0.9980469,"speaker":"A"},{"text":"web","start":1495010,"end":1495250,"confidence":0.9904785,"speaker":"A"},{"text":"authentication","start":1495250,"end":1495810,"confidence":0.9975586,"speaker":"A"},{"text":"token","start":1495810,"end":1496130,"confidence":0.9996745,"speaker":"A"},{"text":"in","start":1496130,"end":1496290,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":1496290,"end":1496450,"confidence":1,"speaker":"A"},{"text":"URL.","start":1496450,"end":1497050,"confidence":0.99731445,"speaker":"A"},{"text":"So","start":1498570,"end":1498970,"confidence":0.9921875,"speaker":"A"},{"text":"you","start":1499050,"end":1499330,"confidence":0.9794922,"speaker":"A"},{"text":"put,","start":1499330,"end":1499610,"confidence":0.9970703,"speaker":"A"},{"text":"basically","start":1500010,"end":1500410,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1500410,"end":1500570,"confidence":0.71972656,"speaker":"A"},{"text":"have","start":1500570,"end":1500690,"confidence":0.99853516,"speaker":"A"},{"text":"your","start":1500690,"end":1500850,"confidence":1,"speaker":"A"},{"text":"website,","start":1500850,"end":1501130,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1501450,"end":1501850,"confidence":0.9995117,"speaker":"A"},{"text":"add","start":1501850,"end":1502130,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":1502130,"end":1502290,"confidence":0.9995117,"speaker":"A"},{"text":"JavaScript,","start":1502290,"end":1503050,"confidence":0.9950358,"speaker":"A"},{"text":"you","start":1503210,"end":1503490,"confidence":0.99658203,"speaker":"A"},{"text":"need","start":1503490,"end":1503770,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1504330,"end":1504730,"confidence":0.99902344,"speaker":"A"},{"text":"add","start":1504970,"end":1505330,"confidence":0.9892578,"speaker":"A"},{"text":"the","start":1505330,"end":1505570,"confidence":0.9975586,"speaker":"A"},{"text":"sign","start":1505570,"end":1505770,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1505770,"end":1505970,"confidence":0.99609375,"speaker":"A"},{"text":"with","start":1505970,"end":1506170,"confidence":1,"speaker":"A"},{"text":"Apple.","start":1506170,"end":1506650,"confidence":0.9987793,"speaker":"A"},{"text":"Oh,","start":1506970,"end":1507330,"confidence":0.8078613,"speaker":"A"},{"text":"here's","start":1507330,"end":1507650,"confidence":0.9991862,"speaker":"A"},{"text":"Josh.","start":1507650,"end":1508010,"confidence":0.9987793,"speaker":"A"},{"text":"Oh","start":1514310,"end":1514510,"confidence":0.9213867,"speaker":"A"},{"text":"cool.","start":1514510,"end":1514870,"confidence":0.99902344,"speaker":"A"},{"text":"Josh,","start":1514870,"end":1515350,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1515350,"end":1515590,"confidence":0.97265625,"speaker":"A"},{"text":"there?","start":1515590,"end":1515910,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1518790,"end":1519110,"confidence":0.99853516,"speaker":"C"},{"text":"hope","start":1519110,"end":1519390,"confidence":1,"speaker":"C"},{"text":"so.","start":1519390,"end":1519750,"confidence":0.99902344,"speaker":"C"},{"text":"Good.","start":1520710,"end":1521070,"confidence":0.9868164,"speaker":"A"},{"text":"Okay.","start":1521070,"end":1521590,"confidence":0.97753906,"speaker":"A"},{"text":"Hey,","start":1521750,"end":1522110,"confidence":0.9992676,"speaker":"A"},{"text":"we","start":1522110,"end":1522230,"confidence":0.99902344,"speaker":"A"},{"text":"were","start":1522230,"end":1522350,"confidence":0.51660156,"speaker":"A"},{"text":"just","start":1522350,"end":1522510,"confidence":1,"speaker":"A"},{"text":"talking","start":1522510,"end":1522750,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":1522750,"end":1522990,"confidence":0.9970703,"speaker":"A"},{"text":"how","start":1522990,"end":1523230,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1523230,"end":1523430,"confidence":0.9902344,"speaker":"A"},{"text":"set","start":1523430,"end":1523630,"confidence":1,"speaker":"A"},{"text":"up.","start":1523630,"end":1523790,"confidence":0.984375,"speaker":"A"},{"text":"I'm","start":1523790,"end":1523990,"confidence":0.9970703,"speaker":"A"},{"text":"going","start":1523990,"end":1524070,"confidence":0.5854492,"speaker":"A"},{"text":"to","start":1524070,"end":1524150,"confidence":0.9951172,"speaker":"A"},{"text":"go","start":1524150,"end":1524269,"confidence":0.9975586,"speaker":"A"},{"text":"back","start":1524269,"end":1524429,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1524429,"end":1524550,"confidence":0.99902344,"speaker":"A"},{"text":"little","start":1524550,"end":1524630,"confidence":1,"speaker":"A"},{"text":"bit","start":1524630,"end":1524750,"confidence":0.99853516,"speaker":"A"},{"text":"Evan,","start":1524750,"end":1525190,"confidence":0.86279297,"speaker":"A"},{"text":"but","start":1525510,"end":1525790,"confidence":0.98535156,"speaker":"A"},{"text":"not","start":1525790,"end":1525950,"confidence":0.99316406,"speaker":"A"},{"text":"too","start":1525950,"end":1526110,"confidence":0.9980469,"speaker":"A"},{"text":"far","start":1526110,"end":1526310,"confidence":1,"speaker":"A"},{"text":"back.","start":1526310,"end":1526630,"confidence":0.99853516,"speaker":"A"},{"text":"Yeah,","start":1527110,"end":1527430,"confidence":0.9895833,"speaker":"B"},{"text":"no","start":1527430,"end":1527550,"confidence":0.9824219,"speaker":"B"},{"text":"worries.","start":1527550,"end":1527910,"confidence":0.998291,"speaker":"B"},{"text":"That's","start":1527990,"end":1528310,"confidence":0.99625653,"speaker":"A"},{"text":"okay.","start":1528310,"end":1528710,"confidence":0.9635417,"speaker":"A"},{"text":"But","start":1530470,"end":1530750,"confidence":0.9370117,"speaker":"A"},{"text":"we","start":1530750,"end":1530910,"confidence":0.9995117,"speaker":"A"},{"text":"talked","start":1530910,"end":1531110,"confidence":0.97265625,"speaker":"A"},{"text":"about","start":1531110,"end":1531270,"confidence":0.9980469,"speaker":"A"},{"text":"setting","start":1531270,"end":1531510,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":1531510,"end":1531750,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":1531830,"end":1532390,"confidence":0.9980469,"speaker":"A"},{"text":"token","start":1532390,"end":1532950,"confidence":1,"speaker":"A"},{"text":"and","start":1533270,"end":1533590,"confidence":0.9946289,"speaker":"A"},{"text":"how","start":1533590,"end":1533790,"confidence":1,"speaker":"A"},{"text":"to","start":1533790,"end":1533910,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1533910,"end":1534030,"confidence":1,"speaker":"A"},{"text":"that.","start":1534030,"end":1534310,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1535910,"end":1536150,"confidence":0.9707031,"speaker":"A"},{"text":"you","start":1536950,"end":1537350,"confidence":0.9169922,"speaker":"A"},{"text":"go","start":1537430,"end":1537710,"confidence":0.99072266,"speaker":"A"},{"text":"in","start":1537710,"end":1537870,"confidence":0.9941406,"speaker":"A"},{"text":"here,","start":1537870,"end":1538150,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1538150,"end":1538430,"confidence":0.9819336,"speaker":"A"},{"text":"just","start":1538430,"end":1538550,"confidence":0.9970703,"speaker":"A"},{"text":"click","start":1538550,"end":1538790,"confidence":0.9995117,"speaker":"A"},{"text":"plus,","start":1538790,"end":1539110,"confidence":0.9655762,"speaker":"A"},{"text":"you","start":1539110,"end":1539350,"confidence":0.9897461,"speaker":"A"},{"text":"select","start":1539350,"end":1539630,"confidence":0.9995117,"speaker":"A"},{"text":"your","start":1539630,"end":1539790,"confidence":0.9975586,"speaker":"A"},{"text":"sign","start":1539790,"end":1539990,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":1539990,"end":1540190,"confidence":0.9428711,"speaker":"A"},{"text":"callback","start":1540190,"end":1540710,"confidence":0.9742839,"speaker":"A"},{"text":"and","start":1540710,"end":1540950,"confidence":0.99365234,"speaker":"A"},{"text":"you","start":1540950,"end":1541150,"confidence":0.98828125,"speaker":"A"},{"text":"put","start":1541150,"end":1541310,"confidence":1,"speaker":"A"},{"text":"in","start":1541310,"end":1541470,"confidence":0.9379883,"speaker":"A"},{"text":"a","start":1541470,"end":1541670,"confidence":0.9404297,"speaker":"A"},{"text":"name","start":1541670,"end":1541990,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":1542630,"end":1542910,"confidence":0.90283203,"speaker":"A"},{"text":"it'll","start":1542910,"end":1543150,"confidence":0.84277344,"speaker":"A"},{"text":"give","start":1543150,"end":1543310,"confidence":1,"speaker":"A"},{"text":"you","start":1543310,"end":1543590,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":1543750,"end":1544030,"confidence":0.9770508,"speaker":"A"},{"text":"API","start":1544030,"end":1544470,"confidence":0.8105469,"speaker":"A"},{"text":"token","start":1544470,"end":1544950,"confidence":0.9941406,"speaker":"A"},{"text":"once","start":1544950,"end":1545150,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1545150,"end":1545310,"confidence":0.9995117,"speaker":"A"},{"text":"click","start":1545310,"end":1545550,"confidence":0.99975586,"speaker":"A"},{"text":"save.","start":1545550,"end":1545830,"confidence":0.9980469,"speaker":"A"},{"text":"Basically.","start":1545830,"end":1546310,"confidence":0.9953613,"speaker":"A"},{"text":"Come","start":1550549,"end":1550870,"confidence":0.9658203,"speaker":"A"},{"text":"on.","start":1550870,"end":1551190,"confidence":0.99853516,"speaker":"A"},{"text":"The","start":1554470,"end":1554710,"confidence":0.9975586,"speaker":"A"},{"text":"reason","start":1554710,"end":1554910,"confidence":1,"speaker":"A"},{"text":"you","start":1554910,"end":1555150,"confidence":0.84814453,"speaker":"A"},{"text":"want","start":1555150,"end":1555310,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":1555310,"end":1555470,"confidence":0.99658203,"speaker":"A"},{"text":"API","start":1555470,"end":1555830,"confidence":0.79589844,"speaker":"A"},{"text":"token","start":1555830,"end":1556190,"confidence":0.9998372,"speaker":"A"},{"text":"is","start":1556190,"end":1556390,"confidence":0.9941406,"speaker":"A"},{"text":"this","start":1556390,"end":1556590,"confidence":0.99902344,"speaker":"A"},{"text":"allows","start":1556590,"end":1556990,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":1556990,"end":1557190,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1557190,"end":1557390,"confidence":0.9946289,"speaker":"A"},{"text":"then","start":1557390,"end":1557670,"confidence":0.95654297,"speaker":"A"},{"text":"have","start":1558550,"end":1558830,"confidence":0.9995117,"speaker":"A"},{"text":"users","start":1558830,"end":1559350,"confidence":0.99886066,"speaker":"A"},{"text":"Sign","start":1559350,"end":1559670,"confidence":1,"speaker":"A"},{"text":"in","start":1559670,"end":1559990,"confidence":0.9448242,"speaker":"A"},{"text":"to","start":1559990,"end":1560390,"confidence":0.9980469,"speaker":"A"},{"text":"CloudKit","start":1560390,"end":1561190,"confidence":0.97046,"speaker":"A"},{"text":"either","start":1562820,"end":1563060,"confidence":0.99902344,"speaker":"A"},{"text":"using,","start":1563060,"end":1563380,"confidence":0.9873047,"speaker":"A"},{"text":"using","start":1565140,"end":1565500,"confidence":1,"speaker":"A"},{"text":"the","start":1565500,"end":1565860,"confidence":0.9794922,"speaker":"A"},{"text":"the","start":1566420,"end":1566700,"confidence":0.99853516,"speaker":"A"},{"text":"web","start":1566700,"end":1567060,"confidence":0.99975586,"speaker":"A"},{"text":"service","start":1567140,"end":1567540,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":1567620,"end":1567940,"confidence":0.9995117,"speaker":"A"},{"text":"Curl","start":1567940,"end":1568580,"confidence":0.8334961,"speaker":"A"},{"text":"or","start":1568900,"end":1569300,"confidence":1,"speaker":"A"},{"text":"you","start":1569300,"end":1569580,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":1569580,"end":1569820,"confidence":0.99609375,"speaker":"A"},{"text":"also","start":1569820,"end":1570140,"confidence":1,"speaker":"A"},{"text":"do","start":1570140,"end":1570380,"confidence":1,"speaker":"A"},{"text":"it","start":1570380,"end":1570540,"confidence":1,"speaker":"A"},{"text":"through","start":1570540,"end":1570700,"confidence":1,"speaker":"A"},{"text":"a","start":1570700,"end":1570860,"confidence":1,"speaker":"A"},{"text":"website","start":1570860,"end":1571100,"confidence":0.9995117,"speaker":"A"},{"text":"using","start":1571100,"end":1571380,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":1571380,"end":1571980,"confidence":0.998291,"speaker":"A"},{"text":"js.","start":1571980,"end":1572500,"confidence":0.83740234,"speaker":"A"},{"text":"So","start":1573780,"end":1574180,"confidence":0.99560547,"speaker":"A"},{"text":"web","start":1574420,"end":1574820,"confidence":0.97021484,"speaker":"A"},{"text":"authentication","start":1574820,"end":1575500,"confidence":0.9995117,"speaker":"A"},{"text":"token","start":1575500,"end":1576100,"confidence":0.9991862,"speaker":"A"},{"text":"we","start":1576100,"end":1576420,"confidence":0.9995117,"speaker":"A"},{"text":"talked","start":1576420,"end":1576700,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":1576700,"end":1576900,"confidence":0.99902344,"speaker":"A"},{"text":"how","start":1576900,"end":1577219,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1577219,"end":1577460,"confidence":1,"speaker":"A"},{"text":"can","start":1577460,"end":1577539,"confidence":1,"speaker":"A"},{"text":"either","start":1577539,"end":1577740,"confidence":1,"speaker":"A"},{"text":"do","start":1577740,"end":1577900,"confidence":1,"speaker":"A"},{"text":"the","start":1577900,"end":1578060,"confidence":1,"speaker":"A"},{"text":"post","start":1578060,"end":1578300,"confidence":1,"speaker":"A"},{"text":"message","start":1578300,"end":1578780,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":1578780,"end":1578980,"confidence":0.8930664,"speaker":"A"},{"text":"you","start":1578980,"end":1579140,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":1579140,"end":1579260,"confidence":0.99853516,"speaker":"A"},{"text":"do","start":1579260,"end":1579380,"confidence":1,"speaker":"A"},{"text":"the","start":1579380,"end":1579500,"confidence":0.99853516,"speaker":"A"},{"text":"URL","start":1579500,"end":1579860,"confidence":0.77905273,"speaker":"A"},{"text":"redirect.","start":1579860,"end":1580420,"confidence":0.99975586,"speaker":"A"},{"text":"Basically","start":1581140,"end":1581700,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":1581700,"end":1582100,"confidence":1,"speaker":"A"},{"text":"have","start":1582100,"end":1582380,"confidence":1,"speaker":"A"},{"text":"the","start":1582380,"end":1582540,"confidence":0.99121094,"speaker":"A"},{"text":"JavaScript","start":1582540,"end":1583020,"confidence":0.9979655,"speaker":"A"},{"text":"on","start":1583020,"end":1583180,"confidence":1,"speaker":"A"},{"text":"your","start":1583180,"end":1583380,"confidence":1,"speaker":"A"},{"text":"website","start":1583380,"end":1583700,"confidence":0.9951172,"speaker":"A"},{"text":"and","start":1584820,"end":1585180,"confidence":0.9980469,"speaker":"A"},{"text":"there","start":1585180,"end":1585420,"confidence":0.58447266,"speaker":"A"},{"text":"has","start":1585420,"end":1585580,"confidence":0.8017578,"speaker":"A"},{"text":"a","start":1585580,"end":1585700,"confidence":1,"speaker":"A"},{"text":"button,","start":1585700,"end":1585980,"confidence":0.998291,"speaker":"A"},{"text":"click","start":1585980,"end":1586260,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":1586260,"end":1586380,"confidence":0.9995117,"speaker":"A"},{"text":"button,","start":1586380,"end":1586620,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":1586620,"end":1586740,"confidence":0.99853516,"speaker":"A"},{"text":"get","start":1586740,"end":1586860,"confidence":0.99560547,"speaker":"A"},{"text":"this","start":1586860,"end":1587020,"confidence":0.9995117,"speaker":"A"},{"text":"nice","start":1587020,"end":1587260,"confidence":0.99975586,"speaker":"A"},{"text":"little","start":1587260,"end":1587460,"confidence":0.9995117,"speaker":"A"},{"text":"window","start":1587460,"end":1587820,"confidence":0.99975586,"speaker":"A"},{"text":"here","start":1587820,"end":1588100,"confidence":0.9951172,"speaker":"A"},{"text":"sign","start":1588780,"end":1588940,"confidence":0.95947266,"speaker":"A"},{"text":"in","start":1588940,"end":1589260,"confidence":0.99072266,"speaker":"A"},{"text":"and","start":1590860,"end":1591140,"confidence":0.9550781,"speaker":"A"},{"text":"then","start":1591140,"end":1591420,"confidence":0.9970703,"speaker":"A"},{"text":"when","start":1591820,"end":1592100,"confidence":1,"speaker":"A"},{"text":"you","start":1592100,"end":1592300,"confidence":0.9995117,"speaker":"A"},{"text":"sign","start":1592300,"end":1592540,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1592540,"end":1592820,"confidence":0.98583984,"speaker":"A"},{"text":"if","start":1592820,"end":1593060,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1593060,"end":1593340,"confidence":0.9995117,"speaker":"A"},{"text":"had","start":1593340,"end":1593660,"confidence":0.9121094,"speaker":"A"},{"text":"selected","start":1593660,"end":1594060,"confidence":0.9992676,"speaker":"A"},{"text":"post","start":1594060,"end":1594380,"confidence":0.9975586,"speaker":"A"},{"text":"message,","start":1594380,"end":1595020,"confidence":0.984375,"speaker":"A"},{"text":"you'll","start":1595340,"end":1595700,"confidence":0.9923503,"speaker":"A"},{"text":"get","start":1595700,"end":1595860,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1595860,"end":1596020,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1596020,"end":1596260,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":1596260,"end":1597020,"confidence":0.96813965,"speaker":"A"},{"text":"token","start":1597020,"end":1597540,"confidence":0.9998372,"speaker":"A"},{"text":"and","start":1597540,"end":1597820,"confidence":0.5283203,"speaker":"A"},{"text":"the","start":1597820,"end":1598020,"confidence":0.9995117,"speaker":"A"},{"text":"data","start":1598020,"end":1598260,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1598260,"end":1598500,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1598500,"end":1598660,"confidence":0.9995117,"speaker":"A"},{"text":"event","start":1598660,"end":1598940,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1598940,"end":1599260,"confidence":0.9291992,"speaker":"A"},{"text":"JavaScript","start":1599260,"end":1600060,"confidence":0.99348956,"speaker":"A"},{"text":"or","start":1600540,"end":1600900,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1600900,"end":1601140,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1601140,"end":1601300,"confidence":0.87109375,"speaker":"A"},{"text":"get","start":1601300,"end":1601460,"confidence":1,"speaker":"A"},{"text":"the","start":1601460,"end":1601580,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1601580,"end":1601780,"confidence":0.9980469,"speaker":"A"},{"text":"authentication","start":1601780,"end":1602460,"confidence":0.8979492,"speaker":"A"},{"text":"token","start":1602460,"end":1602860,"confidence":0.9996745,"speaker":"A"},{"text":"as","start":1602860,"end":1603060,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1603060,"end":1603220,"confidence":0.98779297,"speaker":"A"},{"text":"URL","start":1603220,"end":1603820,"confidence":0.86157227,"speaker":"A"},{"text":"in","start":1604300,"end":1604579,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":1604579,"end":1604739,"confidence":1,"speaker":"A"},{"text":"callback","start":1604739,"end":1605260,"confidence":0.9983724,"speaker":"A"},{"text":"URL","start":1605260,"end":1605780,"confidence":0.8745117,"speaker":"A"},{"text":"here.","start":1605780,"end":1606140,"confidence":0.9975586,"speaker":"A"},{"text":"Does","start":1606780,"end":1607060,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1607060,"end":1607220,"confidence":0.9995117,"speaker":"A"},{"text":"make","start":1607220,"end":1607420,"confidence":0.9926758,"speaker":"A"},{"text":"sense?","start":1607420,"end":1607820,"confidence":0.9995117,"speaker":"A"},{"text":"Yep.","start":1610860,"end":1611420,"confidence":0.7561035,"speaker":"B"},{"text":"Yeah.","start":1612220,"end":1612860,"confidence":0.94124347,"speaker":"A"},{"text":"In","start":1613420,"end":1613740,"confidence":0.9975586,"speaker":"A"},{"text":"some","start":1613740,"end":1613940,"confidence":1,"speaker":"A"},{"text":"cases","start":1613940,"end":1614220,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1614380,"end":1614660,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1614660,"end":1614940,"confidence":1,"speaker":"A"},{"text":"scour","start":1615180,"end":1615620,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":1615620,"end":1615860,"confidence":0.9995117,"speaker":"A"},{"text":"Internet","start":1615860,"end":1616295,"confidence":0.99780273,"speaker":"A"},{"text":"so","start":1616295,"end":1616450,"confidence":0.37280273,"speaker":"A"},{"text":"Stack","start":1616520,"end":1616720,"confidence":0.94799805,"speaker":"A"},{"text":"overflow","start":1616720,"end":1617120,"confidence":0.9749756,"speaker":"A"},{"text":"will","start":1617120,"end":1617280,"confidence":0.9916992,"speaker":"A"},{"text":"tell","start":1617280,"end":1617440,"confidence":1,"speaker":"A"},{"text":"you","start":1617440,"end":1617600,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1617600,"end":1617800,"confidence":0.99658203,"speaker":"A"},{"text":"this","start":1617800,"end":1618000,"confidence":0.99902344,"speaker":"A"},{"text":"has","start":1618000,"end":1618200,"confidence":0.9765625,"speaker":"A"},{"text":"happened","start":1618200,"end":1618520,"confidence":0.99975586,"speaker":"A"},{"text":"to","start":1618520,"end":1618640,"confidence":0.9995117,"speaker":"A"},{"text":"me","start":1618640,"end":1618920,"confidence":0.9995117,"speaker":"A"},{"text":"sometimes","start":1619240,"end":1619720,"confidence":0.9998372,"speaker":"A"},{"text":"it","start":1619720,"end":1619800,"confidence":0.99902344,"speaker":"A"},{"text":"will","start":1619800,"end":1619920,"confidence":0.99853516,"speaker":"A"},{"text":"not","start":1619920,"end":1620080,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":1620080,"end":1620360,"confidence":0.99902344,"speaker":"A"},{"text":"CK","start":1620360,"end":1620920,"confidence":0.89404297,"speaker":"A"},{"text":"web","start":1620920,"end":1621200,"confidence":0.9916992,"speaker":"A"},{"text":"authentication","start":1621200,"end":1621880,"confidence":0.9996338,"speaker":"A"},{"text":"token,","start":1621880,"end":1622360,"confidence":0.9995117,"speaker":"A"},{"text":"sometimes","start":1622360,"end":1622760,"confidence":0.9954427,"speaker":"A"},{"text":"it'll","start":1622760,"end":1623000,"confidence":0.8121745,"speaker":"A"},{"text":"be","start":1623000,"end":1623080,"confidence":0.9995117,"speaker":"A"},{"text":"CK","start":1623080,"end":1623480,"confidence":0.8876953,"speaker":"A"},{"text":"session","start":1623480,"end":1624040,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":1624360,"end":1624760,"confidence":0.99853516,"speaker":"A"},{"text":"that's","start":1625240,"end":1625600,"confidence":0.9996745,"speaker":"A"},{"text":"what","start":1625600,"end":1625760,"confidence":0.99560547,"speaker":"A"},{"text":"Apple","start":1625760,"end":1626040,"confidence":0.99560547,"speaker":"A"},{"text":"likes","start":1626040,"end":1626280,"confidence":0.98999023,"speaker":"A"},{"text":"to","start":1626280,"end":1626360,"confidence":0.9995117,"speaker":"A"},{"text":"do.","start":1626360,"end":1626600,"confidence":0.9995117,"speaker":"A"},{"text":"But","start":1629080,"end":1629360,"confidence":0.99316406,"speaker":"A"},{"text":"it's","start":1629360,"end":1629560,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1629560,"end":1629680,"confidence":1,"speaker":"A"},{"text":"same","start":1629680,"end":1629840,"confidence":1,"speaker":"A"},{"text":"thing.","start":1629840,"end":1630120,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1630200,"end":1630480,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1630480,"end":1630640,"confidence":0.9980469,"speaker":"A"},{"text":"basically","start":1630640,"end":1630920,"confidence":0.99975586,"speaker":"A"},{"text":"want","start":1630920,"end":1631120,"confidence":0.8725586,"speaker":"A"},{"text":"to","start":1631120,"end":1631240,"confidence":1,"speaker":"A"},{"text":"look","start":1631240,"end":1631320,"confidence":1,"speaker":"A"},{"text":"for","start":1631320,"end":1631440,"confidence":1,"speaker":"A"},{"text":"either","start":1631440,"end":1631720,"confidence":0.99975586,"speaker":"A"},{"text":"property","start":1631720,"end":1632200,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":1632200,"end":1632520,"confidence":0.9995117,"speaker":"A"},{"text":"query","start":1632680,"end":1633160,"confidence":0.97436523,"speaker":"A"},{"text":"parameter","start":1633240,"end":1633840,"confidence":0.9998372,"speaker":"A"},{"text":"name","start":1633840,"end":1634160,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":1634160,"end":1634400,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1634400,"end":1634560,"confidence":0.9980469,"speaker":"A"},{"text":"should","start":1634560,"end":1634720,"confidence":1,"speaker":"A"},{"text":"be","start":1634720,"end":1634880,"confidence":1,"speaker":"A"},{"text":"good","start":1634880,"end":1635040,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1635040,"end":1635200,"confidence":0.9980469,"speaker":"A"},{"text":"go","start":1635200,"end":1635480,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":1636360,"end":1636640,"confidence":0.99560547,"speaker":"A"},{"text":"then","start":1636640,"end":1636760,"confidence":1,"speaker":"A"},{"text":"you'll","start":1636760,"end":1636960,"confidence":0.9902344,"speaker":"A"},{"text":"have","start":1636960,"end":1637080,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1637080,"end":1637160,"confidence":0.99902344,"speaker":"A"},{"text":"user","start":1637160,"end":1637400,"confidence":0.99902344,"speaker":"A"},{"text":"as","start":1637400,"end":1637520,"confidence":0.4970703,"speaker":"A"},{"text":"well","start":1637520,"end":1637800,"confidence":0.99316406,"speaker":"A"},{"text":"authentication","start":1637800,"end":1638520,"confidence":0.99902344,"speaker":"A"},{"text":"token","start":1638520,"end":1639080,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1639960,"end":1640240,"confidence":0.98876953,"speaker":"A"},{"text":"could","start":1640240,"end":1640400,"confidence":0.9658203,"speaker":"A"},{"text":"do.","start":1640400,"end":1640680,"confidence":0.9926758,"speaker":"A"},{"text":"What","start":1640920,"end":1641240,"confidence":0.9736328,"speaker":"A"},{"text":"I,","start":1641240,"end":1641560,"confidence":0.9926758,"speaker":"A"},{"text":"what","start":1641720,"end":1642000,"confidence":0.9086914,"speaker":"A"},{"text":"I've","start":1642000,"end":1642200,"confidence":0.99527997,"speaker":"A"},{"text":"been","start":1642200,"end":1642360,"confidence":0.9995117,"speaker":"A"},{"text":"doing","start":1642360,"end":1642680,"confidence":0.9995117,"speaker":"A"},{"text":"is,","start":1643490,"end":1643730,"confidence":0.9863281,"speaker":"A"},{"text":"is","start":1645170,"end":1645490,"confidence":0.94628906,"speaker":"A"},{"text":"I've","start":1645490,"end":1645850,"confidence":0.9996745,"speaker":"A"},{"text":"been","start":1645850,"end":1646130,"confidence":0.99853516,"speaker":"A"},{"text":"take","start":1647330,"end":1647730,"confidence":0.9165039,"speaker":"A"},{"text":"like","start":1647730,"end":1648050,"confidence":0.99902344,"speaker":"A"},{"text":"making","start":1648050,"end":1648290,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1648290,"end":1648490,"confidence":0.9995117,"speaker":"A"},{"text":"call","start":1648490,"end":1648690,"confidence":1,"speaker":"A"},{"text":"to","start":1648690,"end":1648930,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1648930,"end":1649130,"confidence":0.7597656,"speaker":"A"},{"text":"like","start":1649130,"end":1649370,"confidence":0.98779297,"speaker":"A"},{"text":"local","start":1649370,"end":1649690,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1649690,"end":1650170,"confidence":0.99975586,"speaker":"A"},{"text":"for","start":1650170,"end":1650330,"confidence":0.9995117,"speaker":"A"},{"text":"instance","start":1650330,"end":1650770,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":1651330,"end":1651650,"confidence":0.99853516,"speaker":"A"},{"text":"then","start":1651650,"end":1651970,"confidence":0.99902344,"speaker":"A"},{"text":"essentially","start":1651970,"end":1652690,"confidence":0.9987793,"speaker":"A"},{"text":"then","start":1653410,"end":1653690,"confidence":0.8886719,"speaker":"A"},{"text":"I","start":1653690,"end":1653810,"confidence":1,"speaker":"A"},{"text":"could","start":1653810,"end":1653930,"confidence":0.6508789,"speaker":"A"},{"text":"do","start":1653930,"end":1654090,"confidence":0.9995117,"speaker":"A"},{"text":"whatever","start":1654090,"end":1654330,"confidence":1,"speaker":"A"},{"text":"I","start":1654330,"end":1654490,"confidence":0.9995117,"speaker":"A"},{"text":"want","start":1654490,"end":1654690,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":1654690,"end":1654890,"confidence":0.99853516,"speaker":"A"},{"text":"that","start":1654890,"end":1655050,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1655050,"end":1655290,"confidence":0.9897461,"speaker":"A"},{"text":"authentication","start":1655290,"end":1655970,"confidence":0.9991455,"speaker":"A"},{"text":"token.","start":1655970,"end":1656330,"confidence":0.9996745,"speaker":"A"},{"text":"As","start":1656330,"end":1656490,"confidence":0.9995117,"speaker":"A"},{"text":"long","start":1656490,"end":1656610,"confidence":1,"speaker":"A"},{"text":"as","start":1656610,"end":1656690,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1656690,"end":1656770,"confidence":1,"speaker":"A"},{"text":"have","start":1656770,"end":1656890,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1656890,"end":1657010,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1657010,"end":1657210,"confidence":0.998291,"speaker":"A"},{"text":"authentication","start":1657210,"end":1657730,"confidence":0.99975586,"speaker":"A"},{"text":"token","start":1657730,"end":1658090,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":1658090,"end":1658210,"confidence":0.9355469,"speaker":"A"},{"text":"the","start":1658210,"end":1658330,"confidence":0.99853516,"speaker":"A"},{"text":"API","start":1658330,"end":1658770,"confidence":0.9987793,"speaker":"A"},{"text":"token","start":1658770,"end":1659329,"confidence":0.9996745,"speaker":"A"},{"text":"you","start":1659570,"end":1659850,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1659850,"end":1660010,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1660010,"end":1660170,"confidence":1,"speaker":"A"},{"text":"anything","start":1660170,"end":1660570,"confidence":0.99975586,"speaker":"A"},{"text":"on","start":1660570,"end":1660730,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1660730,"end":1660850,"confidence":0.99902344,"speaker":"A"},{"text":"private","start":1660850,"end":1661050,"confidence":1,"speaker":"A"},{"text":"database","start":1661050,"end":1661810,"confidence":0.99934894,"speaker":"A"},{"text":"that","start":1662530,"end":1662810,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":1662810,"end":1662930,"confidence":0.9995117,"speaker":"A"},{"text":"user","start":1662930,"end":1663210,"confidence":1,"speaker":"A"},{"text":"has","start":1663210,"end":1663410,"confidence":0.99902344,"speaker":"A"},{"text":"rights","start":1663410,"end":1663690,"confidence":0.9975586,"speaker":"A"},{"text":"to.","start":1663690,"end":1664050,"confidence":0.9824219,"speaker":"A"},{"text":"So","start":1664450,"end":1664850,"confidence":0.9941406,"speaker":"A"},{"text":"you","start":1665890,"end":1666170,"confidence":0.98876953,"speaker":"A"},{"text":"can","start":1666170,"end":1666330,"confidence":0.95703125,"speaker":"A"},{"text":"go,","start":1666330,"end":1666570,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1666570,"end":1666810,"confidence":0.99560547,"speaker":"A"},{"text":"can","start":1666810,"end":1666970,"confidence":0.5966797,"speaker":"A"},{"text":"go","start":1666970,"end":1667130,"confidence":1,"speaker":"A"},{"text":"to","start":1667130,"end":1667250,"confidence":0.9980469,"speaker":"A"},{"text":"town","start":1667250,"end":1667410,"confidence":0.99902344,"speaker":"A"},{"text":"with","start":1667410,"end":1667610,"confidence":0.99609375,"speaker":"A"},{"text":"that","start":1667610,"end":1667890,"confidence":0.9848633,"speaker":"A"},{"text":"all","start":1669420,"end":1669540,"confidence":0.99365234,"speaker":"A"},{"text":"this","start":1669540,"end":1669700,"confidence":0.8154297,"speaker":"A"},{"text":"stuff","start":1669700,"end":1669900,"confidence":1,"speaker":"A"},{"text":"gets","start":1669900,"end":1670060,"confidence":0.99487305,"speaker":"A"},{"text":"Swift","start":1670060,"end":1670260,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":1670260,"end":1670420,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1670420,"end":1670540,"confidence":0.9995117,"speaker":"A"},{"text":"cookie","start":1670540,"end":1671020,"confidence":1,"speaker":"A"},{"text":"too.","start":1671020,"end":1671420,"confidence":0.9838867,"speaker":"A"},{"text":"So","start":1671580,"end":1671820,"confidence":0.99658203,"speaker":"A"},{"text":"that","start":1671820,"end":1671940,"confidence":1,"speaker":"A"},{"text":"way","start":1671940,"end":1672180,"confidence":0.9995117,"speaker":"A"},{"text":"it'll","start":1672180,"end":1672540,"confidence":0.8470052,"speaker":"A"},{"text":"work.","start":1672540,"end":1672860,"confidence":1,"speaker":"A"},{"text":"When","start":1673740,"end":1674020,"confidence":1,"speaker":"A"},{"text":"you","start":1674020,"end":1674220,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1674220,"end":1674460,"confidence":1,"speaker":"A"},{"text":"back,","start":1674460,"end":1674700,"confidence":1,"speaker":"A"},{"text":"if","start":1674700,"end":1674940,"confidence":0.53125,"speaker":"A"},{"text":"you","start":1674940,"end":1675260,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1675500,"end":1675900,"confidence":0.9995117,"speaker":"A"},{"text":"checked","start":1675900,"end":1676420,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":1676420,"end":1676580,"confidence":1,"speaker":"A"},{"text":"box","start":1676580,"end":1676900,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":1676900,"end":1677180,"confidence":0.99902344,"speaker":"A"},{"text":"allow,","start":1677180,"end":1677500,"confidence":0.99560547,"speaker":"A"},{"text":"it's","start":1678780,"end":1679100,"confidence":0.9899089,"speaker":"A"},{"text":"either","start":1679100,"end":1679340,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":1679340,"end":1679540,"confidence":0.9995117,"speaker":"A"},{"text":"box","start":1679540,"end":1679780,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":1679780,"end":1679980,"confidence":0.99902344,"speaker":"A"},{"text":"JavaScript","start":1679980,"end":1680580,"confidence":0.99934894,"speaker":"A"},{"text":"method","start":1680580,"end":1680900,"confidence":0.99348956,"speaker":"A"},{"text":"property","start":1680900,"end":1681260,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1681260,"end":1681460,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1681460,"end":1681700,"confidence":0.9013672,"speaker":"A"},{"text":"say,","start":1681700,"end":1681940,"confidence":0.9975586,"speaker":"A"},{"text":"hey,","start":1681940,"end":1682180,"confidence":0.9992676,"speaker":"A"},{"text":"I","start":1682180,"end":1682300,"confidence":1,"speaker":"A"},{"text":"want","start":1682300,"end":1682420,"confidence":1,"speaker":"A"},{"text":"this","start":1682420,"end":1682580,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":1682580,"end":1682740,"confidence":1,"speaker":"A"},{"text":"persist.","start":1682740,"end":1683260,"confidence":0.9992676,"speaker":"A"},{"text":"It'll","start":1683420,"end":1683780,"confidence":0.9715169,"speaker":"A"},{"text":"be","start":1683780,"end":1683900,"confidence":1,"speaker":"A"},{"text":"Swift","start":1683900,"end":1684100,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":1684100,"end":1684260,"confidence":0.9121094,"speaker":"A"},{"text":"a,","start":1684260,"end":1684420,"confidence":0.7871094,"speaker":"A"},{"text":"in","start":1684420,"end":1684580,"confidence":0.71191406,"speaker":"A"},{"text":"a","start":1684580,"end":1684740,"confidence":0.9995117,"speaker":"A"},{"text":"cookie","start":1684740,"end":1685020,"confidence":0.99975586,"speaker":"A"},{"text":"as","start":1685020,"end":1685179,"confidence":1,"speaker":"A"},{"text":"well.","start":1685179,"end":1685460,"confidence":1,"speaker":"A"},{"text":"So","start":1685460,"end":1685700,"confidence":0.99658203,"speaker":"A"},{"text":"if","start":1685700,"end":1685820,"confidence":1,"speaker":"A"},{"text":"you","start":1685820,"end":1685940,"confidence":1,"speaker":"A"},{"text":"want","start":1685940,"end":1686060,"confidence":0.95751953,"speaker":"A"},{"text":"to","start":1686060,"end":1686220,"confidence":0.97314453,"speaker":"A"},{"text":"spelunk","start":1686220,"end":1686820,"confidence":0.9758301,"speaker":"A"},{"text":"your","start":1686820,"end":1686980,"confidence":0.99560547,"speaker":"A"},{"text":"cookies,","start":1686980,"end":1687260,"confidence":1,"speaker":"A"},{"text":"you","start":1687340,"end":1687580,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1687580,"end":1687820,"confidence":0.9995117,"speaker":"A"},{"text":"see","start":1687980,"end":1688300,"confidence":0.78027344,"speaker":"A"},{"text":"the","start":1688300,"end":1688500,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1688500,"end":1688740,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":1688740,"end":1689340,"confidence":0.99938965,"speaker":"A"},{"text":"token","start":1689340,"end":1689740,"confidence":0.99902344,"speaker":"A"},{"text":"there.","start":1689740,"end":1690060,"confidence":0.99560547,"speaker":"A"},{"text":"So","start":1691500,"end":1691780,"confidence":0.9921875,"speaker":"A"},{"text":"that's","start":1691780,"end":1692100,"confidence":0.9995117,"speaker":"A"},{"text":"actually","start":1692100,"end":1692300,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1692300,"end":1692540,"confidence":0.99609375,"speaker":"A"},{"text":"easier","start":1692540,"end":1692900,"confidence":0.99975586,"speaker":"A"},{"text":"of","start":1692900,"end":1693020,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1693020,"end":1693180,"confidence":0.99902344,"speaker":"A"},{"text":"two.","start":1693180,"end":1693500,"confidence":0.9926758,"speaker":"A"},{"text":"So","start":1694380,"end":1694660,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":1694660,"end":1694820,"confidence":1,"speaker":"A"},{"text":"gives","start":1694820,"end":1695020,"confidence":1,"speaker":"A"},{"text":"you","start":1695020,"end":1695100,"confidence":1,"speaker":"A"},{"text":"the","start":1695100,"end":1695220,"confidence":0.9995117,"speaker":"A"},{"text":"private","start":1695220,"end":1695420,"confidence":1,"speaker":"A"},{"text":"database","start":1695420,"end":1695940,"confidence":0.9998372,"speaker":"A"},{"text":"for","start":1695940,"end":1696100,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":1696100,"end":1696220,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1696220,"end":1696380,"confidence":1,"speaker":"A"},{"text":"database","start":1696380,"end":1696940,"confidence":0.99886066,"speaker":"A"},{"text":"is","start":1696940,"end":1697140,"confidence":0.98876953,"speaker":"A"},{"text":"where","start":1697140,"end":1697300,"confidence":0.99902344,"speaker":"A"},{"text":"you're","start":1697300,"end":1697500,"confidence":0.9975586,"speaker":"A"},{"text":"going","start":1697500,"end":1697580,"confidence":0.9355469,"speaker":"A"},{"text":"to","start":1697580,"end":1697660,"confidence":0.9980469,"speaker":"A"},{"text":"need","start":1697660,"end":1697820,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":1697820,"end":1697990,"confidence":0.55908203,"speaker":"A"},{"text":"server","start":1698220,"end":1698460,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1698460,"end":1698620,"confidence":0.9536133,"speaker":"A"},{"text":"server","start":1698620,"end":1699020,"confidence":0.99902344,"speaker":"A"},{"text":"authentication.","start":1699020,"end":1699820,"confidence":0.99938965,"speaker":"A"},{"text":"And","start":1701340,"end":1701700,"confidence":0.98876953,"speaker":"A"},{"text":"so","start":1701700,"end":1701940,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1701940,"end":1702100,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1702100,"end":1702300,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1702300,"end":1702620,"confidence":0.9970703,"speaker":"A"},{"text":"it's","start":1703180,"end":1703540,"confidence":0.9996745,"speaker":"A"},{"text":"really","start":1703540,"end":1703820,"confidence":0.99853516,"speaker":"A"},{"text":"actually","start":1703820,"end":1704180,"confidence":0.99853516,"speaker":"A"},{"text":"not","start":1704180,"end":1704420,"confidence":1,"speaker":"A"},{"text":"as","start":1704420,"end":1704620,"confidence":0.99902344,"speaker":"A"},{"text":"bad","start":1704620,"end":1704820,"confidence":1,"speaker":"A"},{"text":"as","start":1704820,"end":1704980,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":1704980,"end":1705140,"confidence":1,"speaker":"A"},{"text":"thought","start":1705140,"end":1705260,"confidence":1,"speaker":"A"},{"text":"it","start":1705260,"end":1705340,"confidence":0.9975586,"speaker":"A"},{"text":"was","start":1705340,"end":1705460,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":1705460,"end":1705580,"confidence":0.8984375,"speaker":"A"},{"text":"to","start":1705580,"end":1705660,"confidence":1,"speaker":"A"},{"text":"be.","start":1705660,"end":1705900,"confidence":1,"speaker":"A"},{"text":"But","start":1705900,"end":1706300,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":1706620,"end":1706940,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1706940,"end":1707220,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":1707220,"end":1707500,"confidence":1,"speaker":"A"},{"text":"the","start":1707500,"end":1707700,"confidence":0.9995117,"speaker":"A"},{"text":"new","start":1707700,"end":1707980,"confidence":0.9970703,"speaker":"A"},{"text":"server","start":1708220,"end":1708620,"confidence":0.99731445,"speaker":"A"},{"text":"to","start":1708620,"end":1708740,"confidence":0.8359375,"speaker":"A"},{"text":"server","start":1708740,"end":1709140,"confidence":0.99731445,"speaker":"A"},{"text":"key,","start":1709140,"end":1709420,"confidence":0.99121094,"speaker":"A"},{"text":"put","start":1709420,"end":1709700,"confidence":0.9951172,"speaker":"A"},{"text":"in","start":1709700,"end":1709900,"confidence":0.9526367,"speaker":"A"},{"text":"a","start":1709900,"end":1710100,"confidence":0.9555664,"speaker":"A"},{"text":"name","start":1710100,"end":1710300,"confidence":0.9941406,"speaker":"A"},{"text":"you","start":1710300,"end":1710500,"confidence":0.99072266,"speaker":"A"},{"text":"want,","start":1710500,"end":1710780,"confidence":0.70458984,"speaker":"A"},{"text":"it'll","start":1711020,"end":1711460,"confidence":0.9889323,"speaker":"A"},{"text":"actually","start":1711460,"end":1711660,"confidence":0.99902344,"speaker":"A"},{"text":"give","start":1711660,"end":1711860,"confidence":1,"speaker":"A"},{"text":"you","start":1711860,"end":1712020,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1712020,"end":1712180,"confidence":0.9995117,"speaker":"A"},{"text":"command","start":1712180,"end":1712500,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1712500,"end":1712660,"confidence":0.9970703,"speaker":"A"},{"text":"need","start":1712660,"end":1712820,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":1712820,"end":1712980,"confidence":1,"speaker":"A"},{"text":"run","start":1712980,"end":1713260,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1713340,"end":1713620,"confidence":0.99853516,"speaker":"A"},{"text":"then","start":1713620,"end":1713780,"confidence":0.9946289,"speaker":"A"},{"text":"you","start":1713780,"end":1713940,"confidence":0.99853516,"speaker":"A"},{"text":"just","start":1713940,"end":1714099,"confidence":0.9995117,"speaker":"A"},{"text":"paste","start":1714099,"end":1714420,"confidence":0.98950195,"speaker":"A"},{"text":"in","start":1714420,"end":1714580,"confidence":0.9951172,"speaker":"A"},{"text":"the","start":1714580,"end":1714700,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1714700,"end":1714900,"confidence":0.9995117,"speaker":"A"},{"text":"key","start":1714900,"end":1715180,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1715180,"end":1715380,"confidence":0.9169922,"speaker":"A"},{"text":"here.","start":1715380,"end":1715660,"confidence":0.9995117,"speaker":"A"},{"text":"That","start":1716380,"end":1716700,"confidence":0.9980469,"speaker":"A"},{"text":"gives","start":1716700,"end":1717060,"confidence":0.9995117,"speaker":"A"},{"text":"you.","start":1717060,"end":1717340,"confidence":0.9995117,"speaker":"A"},{"text":"That","start":1718780,"end":1719060,"confidence":0.8378906,"speaker":"A"},{"text":"will","start":1719060,"end":1719220,"confidence":0.9951172,"speaker":"A"},{"text":"give","start":1719220,"end":1719380,"confidence":1,"speaker":"A"},{"text":"you","start":1719380,"end":1719540,"confidence":1,"speaker":"A"},{"text":"everything","start":1719540,"end":1719780,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1719780,"end":1720020,"confidence":0.99902344,"speaker":"A"},{"text":"need.","start":1720020,"end":1720300,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1720860,"end":1721140,"confidence":0.9995117,"speaker":"A"},{"text":"here's","start":1721140,"end":1721540,"confidence":0.9949544,"speaker":"A"},{"text":"how","start":1721540,"end":1721780,"confidence":1,"speaker":"A"},{"text":"to","start":1721780,"end":1721940,"confidence":0.9995117,"speaker":"A"},{"text":"run","start":1721940,"end":1722100,"confidence":1,"speaker":"A"},{"text":"it.","start":1722100,"end":1722300,"confidence":0.99902344,"speaker":"A"},{"text":"Basically,","start":1722300,"end":1722780,"confidence":0.998291,"speaker":"A"},{"text":"sorry","start":1723990,"end":1724190,"confidence":0.9773763,"speaker":"A"},{"text":"about","start":1724190,"end":1724350,"confidence":0.9819336,"speaker":"A"},{"text":"that.","start":1724350,"end":1724630,"confidence":0.9941406,"speaker":"A"},{"text":"We","start":1737190,"end":1737470,"confidence":0.7998047,"speaker":"A"},{"text":"just","start":1737470,"end":1737670,"confidence":0.99853516,"speaker":"A"},{"text":"run","start":1737670,"end":1737870,"confidence":0.9975586,"speaker":"A"},{"text":"that.","start":1737870,"end":1738150,"confidence":0.9970703,"speaker":"A"},{"text":"That","start":1738470,"end":1738750,"confidence":0.9995117,"speaker":"A"},{"text":"gives","start":1738750,"end":1738950,"confidence":0.99975586,"speaker":"A"},{"text":"us","start":1738950,"end":1739070,"confidence":1,"speaker":"A"},{"text":"the","start":1739070,"end":1739230,"confidence":0.9995117,"speaker":"A"},{"text":"key.","start":1739230,"end":1739510,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":1740710,"end":1740990,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":1740990,"end":1741150,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":1741150,"end":1741310,"confidence":0.99902344,"speaker":"A"},{"text":"ahead","start":1741310,"end":1741550,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":1741550,"end":1741910,"confidence":0.9970703,"speaker":"A"},{"text":"get","start":1742070,"end":1742350,"confidence":1,"speaker":"A"},{"text":"the","start":1742350,"end":1742510,"confidence":1,"speaker":"A"},{"text":"public","start":1742510,"end":1742750,"confidence":1,"speaker":"A"},{"text":"key.","start":1742750,"end":1743110,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":1743190,"end":1743470,"confidence":0.9980469,"speaker":"A"},{"text":"can","start":1743470,"end":1743750,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":1743910,"end":1744270,"confidence":0.99902344,"speaker":"A"},{"text":"pipe","start":1744270,"end":1744670,"confidence":0.9607747,"speaker":"A"},{"text":"it","start":1744670,"end":1744870,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1744870,"end":1745070,"confidence":0.9975586,"speaker":"A"},{"text":"PB","start":1745070,"end":1745390,"confidence":0.79541016,"speaker":"A"},{"text":"Copy","start":1745390,"end":1745990,"confidence":0.9637044,"speaker":"A"},{"text":"and","start":1746470,"end":1746750,"confidence":0.9321289,"speaker":"A"},{"text":"then","start":1746750,"end":1746910,"confidence":0.98779297,"speaker":"A"},{"text":"all","start":1746910,"end":1747070,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":1747070,"end":1747190,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1747190,"end":1747310,"confidence":0.95947266,"speaker":"A"},{"text":"to","start":1747310,"end":1747430,"confidence":0.99609375,"speaker":"A"},{"text":"do","start":1747430,"end":1747590,"confidence":0.99609375,"speaker":"A"},{"text":"is","start":1747590,"end":1747830,"confidence":0.99902344,"speaker":"A"},{"text":"paste","start":1747830,"end":1748110,"confidence":0.9172363,"speaker":"A"},{"text":"that","start":1748110,"end":1748310,"confidence":0.99560547,"speaker":"A"},{"text":"in","start":1748310,"end":1748510,"confidence":0.9970703,"speaker":"A"},{"text":"the","start":1748510,"end":1748670,"confidence":0.99853516,"speaker":"A"},{"text":"box","start":1748670,"end":1749030,"confidence":0.99780273,"speaker":"A"},{"text":"over","start":1750370,"end":1750570,"confidence":0.9951172,"speaker":"A"},{"text":"here.","start":1750570,"end":1750930,"confidence":0.9995117,"speaker":"A"},{"text":"There","start":1757970,"end":1758250,"confidence":0.98046875,"speaker":"A"},{"text":"we","start":1758250,"end":1758410,"confidence":0.5283203,"speaker":"A"},{"text":"go.","start":1758410,"end":1758690,"confidence":1,"speaker":"A"},{"text":"It's","start":1765890,"end":1766250,"confidence":0.9930013,"speaker":"A"},{"text":"pretty","start":1766250,"end":1766570,"confidence":0.9998372,"speaker":"A"},{"text":"complicated","start":1766570,"end":1767250,"confidence":1,"speaker":"A"},{"text":"to","start":1767250,"end":1767490,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":1767490,"end":1767770,"confidence":1,"speaker":"A"},{"text":"the","start":1767770,"end":1768010,"confidence":0.9995117,"speaker":"A"},{"text":"server","start":1768010,"end":1768450,"confidence":0.99975586,"speaker":"A"},{"text":"key.","start":1768450,"end":1768770,"confidence":0.99560547,"speaker":"A"},{"text":"We","start":1770050,"end":1770330,"confidence":0.9951172,"speaker":"A"},{"text":"can","start":1770330,"end":1770490,"confidence":0.99902344,"speaker":"A"},{"text":"spell","start":1770490,"end":1770770,"confidence":0.9838867,"speaker":"A"},{"text":"on","start":1770770,"end":1771050,"confidence":0.8208008,"speaker":"A"},{"text":"the","start":1771050,"end":1771250,"confidence":0.99658203,"speaker":"A"},{"text":"miskit","start":1771250,"end":1771690,"confidence":0.9238281,"speaker":"A"},{"text":"code","start":1771690,"end":1771970,"confidence":0.99348956,"speaker":"A"},{"text":"on","start":1771970,"end":1772090,"confidence":0.9975586,"speaker":"A"},{"text":"how","start":1772090,"end":1772250,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1772250,"end":1772410,"confidence":0.99902344,"speaker":"A"},{"text":"do","start":1772410,"end":1772570,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":1772570,"end":1772850,"confidence":0.9995117,"speaker":"A"},{"text":"because","start":1773170,"end":1773450,"confidence":0.9663086,"speaker":"A"},{"text":"it","start":1773450,"end":1773610,"confidence":0.9995117,"speaker":"A"},{"text":"does","start":1773610,"end":1773810,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1773810,"end":1773970,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":1773970,"end":1774050,"confidence":1,"speaker":"A"},{"text":"of","start":1774050,"end":1774130,"confidence":0.9980469,"speaker":"A"},{"text":"that","start":1774130,"end":1774290,"confidence":0.99560547,"speaker":"A"},{"text":"work","start":1774290,"end":1774530,"confidence":1,"speaker":"A"},{"text":"for","start":1774530,"end":1774730,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1774730,"end":1774930,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":1774930,"end":1775170,"confidence":0.59228516,"speaker":"A"},{"text":"you","start":1775170,"end":1775330,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1775330,"end":1775450,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":1775450,"end":1775730,"confidence":0.9916992,"speaker":"A"},{"text":"But","start":1776610,"end":1776730,"confidence":0.99121094,"speaker":"A"},{"text":"you","start":1776730,"end":1776890,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":1776890,"end":1777090,"confidence":0.9995117,"speaker":"A"},{"text":"need","start":1777090,"end":1777410,"confidence":0.9995117,"speaker":"A"},{"text":"the,","start":1777650,"end":1778050,"confidence":0.8984375,"speaker":"A"},{"text":"the","start":1779170,"end":1779490,"confidence":0.98876953,"speaker":"A"},{"text":"private","start":1779490,"end":1779810,"confidence":0.9995117,"speaker":"A"},{"text":"key,","start":1779890,"end":1780290,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":1780290,"end":1780570,"confidence":0.99121094,"speaker":"A"},{"text":"key","start":1780570,"end":1780810,"confidence":0.9946289,"speaker":"A"},{"text":"id,","start":1780810,"end":1781170,"confidence":0.98583984,"speaker":"A"},{"text":"I","start":1782290,"end":1782570,"confidence":0.90771484,"speaker":"A"},{"text":"think,","start":1782570,"end":1782850,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":1783170,"end":1783450,"confidence":0.8652344,"speaker":"A"},{"text":"think","start":1783450,"end":1783610,"confidence":0.9868164,"speaker":"A"},{"text":"that's","start":1783610,"end":1783810,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":1783810,"end":1784050,"confidence":0.9941406,"speaker":"A"},{"text":"And","start":1784370,"end":1784650,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":1784650,"end":1784890,"confidence":0.94677734,"speaker":"A"},{"text":"you","start":1784890,"end":1785130,"confidence":0.99658203,"speaker":"A"},{"text":"should","start":1785130,"end":1785290,"confidence":1,"speaker":"A"},{"text":"be","start":1785290,"end":1785490,"confidence":1,"speaker":"A"},{"text":"good","start":1785490,"end":1785810,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":1786130,"end":1786490,"confidence":0.9975586,"speaker":"A"},{"text":"having","start":1786490,"end":1786810,"confidence":0.9555664,"speaker":"A"},{"text":"access","start":1786810,"end":1787170,"confidence":1,"speaker":"A"},{"text":"now","start":1787170,"end":1787490,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":1787490,"end":1787770,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1787770,"end":1788010,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1788010,"end":1788290,"confidence":0.9995117,"speaker":"A"},{"text":"database.","start":1789330,"end":1790130,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":1790850,"end":1791250,"confidence":0.98876953,"speaker":"A"},{"text":"just","start":1791570,"end":1791889,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1791889,"end":1792050,"confidence":0.99853516,"speaker":"A"},{"text":"go","start":1792050,"end":1792209,"confidence":0.99902344,"speaker":"A"},{"text":"over,","start":1792209,"end":1792530,"confidence":1,"speaker":"A"},{"text":"there's","start":1792610,"end":1793050,"confidence":0.9892578,"speaker":"A"},{"text":"differences","start":1793050,"end":1793450,"confidence":0.9995117,"speaker":"A"},{"text":"between","start":1793450,"end":1793770,"confidence":1,"speaker":"A"},{"text":"the","start":1793770,"end":1793970,"confidence":0.9995117,"speaker":"A"},{"text":"public","start":1793970,"end":1794210,"confidence":1,"speaker":"A"},{"text":"and","start":1794210,"end":1794490,"confidence":0.99902344,"speaker":"A"},{"text":"private","start":1794490,"end":1794730,"confidence":1,"speaker":"A"},{"text":"database.","start":1794730,"end":1795490,"confidence":0.99820966,"speaker":"A"},{"text":"So","start":1797170,"end":1797570,"confidence":0.99609375,"speaker":"A"},{"text":"this","start":1797730,"end":1798050,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1798050,"end":1798370,"confidence":0.9995117,"speaker":"A"},{"text":"query.","start":1798530,"end":1799090,"confidence":0.9975586,"speaker":"A"},{"text":"You","start":1799570,"end":1799810,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1799810,"end":1799930,"confidence":0.5439453,"speaker":"A"},{"text":"see","start":1799930,"end":1800090,"confidence":0.99609375,"speaker":"A"},{"text":"my","start":1800090,"end":1800250,"confidence":0.8847656,"speaker":"A"},{"text":"cursor,","start":1800250,"end":1800650,"confidence":0.9938151,"speaker":"A"},{"text":"right?","start":1800650,"end":1800930,"confidence":0.97265625,"speaker":"A"},{"text":"Query","start":1800930,"end":1801330,"confidence":0.9904785,"speaker":"A"},{"text":"and","start":1801330,"end":1801530,"confidence":0.53759766,"speaker":"A"},{"text":"lookup","start":1801530,"end":1802010,"confidence":0.94018555,"speaker":"A"},{"text":"of","start":1802010,"end":1802330,"confidence":0.9916992,"speaker":"A"},{"text":"records","start":1802330,"end":1803010,"confidence":0.99975586,"speaker":"A"},{"text":"is","start":1803010,"end":1803290,"confidence":0.9995117,"speaker":"A"},{"text":"available","start":1803290,"end":1803570,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":1803650,"end":1803970,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":1803970,"end":1804290,"confidence":0.99658203,"speaker":"A"},{"text":"but","start":1805270,"end":1805510,"confidence":0.9897461,"speaker":"A"},{"text":"file","start":1805590,"end":1806030,"confidence":0.9970703,"speaker":"A"},{"text":"changes","start":1806030,"end":1806630,"confidence":0.9992676,"speaker":"A"},{"text":"or,","start":1806790,"end":1807110,"confidence":0.97314453,"speaker":"A"},{"text":"excuse","start":1807110,"end":1807430,"confidence":0.99820966,"speaker":"A"},{"text":"me,","start":1807430,"end":1807670,"confidence":0.9995117,"speaker":"A"},{"text":"record","start":1807990,"end":1808350,"confidence":0.99609375,"speaker":"A"},{"text":"changes.","start":1808350,"end":1808830,"confidence":0.99975586,"speaker":"A"},{"text":"It's","start":1808830,"end":1809070,"confidence":0.8819987,"speaker":"A"},{"text":"not","start":1809070,"end":1809230,"confidence":1,"speaker":"A"},{"text":"available","start":1809230,"end":1809510,"confidence":0.99853516,"speaker":"A"},{"text":"on","start":1809830,"end":1810150,"confidence":0.9160156,"speaker":"A"},{"text":"public","start":1810150,"end":1810470,"confidence":0.9995117,"speaker":"A"},{"text":"zones,","start":1810950,"end":1811390,"confidence":0.9909668,"speaker":"A"},{"text":"aren't","start":1811390,"end":1811670,"confidence":0.9958496,"speaker":"A"},{"text":"really","start":1811670,"end":1811830,"confidence":1,"speaker":"A"},{"text":"available","start":1811830,"end":1812150,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1812150,"end":1812430,"confidence":0.9394531,"speaker":"A"},{"text":"public","start":1812430,"end":1812710,"confidence":1,"speaker":"A"},{"text":"zone","start":1812790,"end":1813190,"confidence":0.96240234,"speaker":"A"},{"text":"changes","start":1813190,"end":1813550,"confidence":0.8989258,"speaker":"A"},{"text":"aren't","start":1813550,"end":1813870,"confidence":0.9959717,"speaker":"A"},{"text":"available","start":1813870,"end":1814150,"confidence":1,"speaker":"A"},{"text":"in","start":1814470,"end":1814750,"confidence":0.9667969,"speaker":"A"},{"text":"public","start":1814750,"end":1815030,"confidence":1,"speaker":"A"},{"text":"notifications.","start":1815670,"end":1816470,"confidence":0.9949544,"speaker":"A"},{"text":"Zone","start":1816550,"end":1816950,"confidence":0.94677734,"speaker":"A"},{"text":"notifications","start":1816950,"end":1817630,"confidence":0.9996745,"speaker":"A"},{"text":"aren't","start":1817630,"end":1817950,"confidence":0.9765625,"speaker":"A"},{"text":"available","start":1817950,"end":1818230,"confidence":1,"speaker":"A"},{"text":"in","start":1818310,"end":1818590,"confidence":0.9941406,"speaker":"A"},{"text":"public,","start":1818590,"end":1818870,"confidence":1,"speaker":"A"},{"text":"but","start":1819670,"end":1820070,"confidence":0.9921875,"speaker":"A"},{"text":"query","start":1820070,"end":1820550,"confidence":0.82421875,"speaker":"A"},{"text":"notifications","start":1820709,"end":1821510,"confidence":0.9996745,"speaker":"A"},{"text":"are.","start":1821590,"end":1821990,"confidence":0.9902344,"speaker":"A"},{"text":"And","start":1821990,"end":1822390,"confidence":0.9921875,"speaker":"A"},{"text":"you","start":1822390,"end":1822630,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1822630,"end":1822750,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":1822750,"end":1822990,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1822990,"end":1823350,"confidence":1,"speaker":"A"},{"text":"any","start":1823350,"end":1823750,"confidence":0.99853516,"speaker":"A"},{"text":"stuff","start":1823750,"end":1824150,"confidence":0.9996745,"speaker":"A"},{"text":"with","start":1824150,"end":1824470,"confidence":0.98876953,"speaker":"A"},{"text":"assets","start":1824710,"end":1825270,"confidence":0.7792969,"speaker":"A"},{"text":"which","start":1825350,"end":1825630,"confidence":0.99853516,"speaker":"A"},{"text":"are","start":1825630,"end":1825790,"confidence":1,"speaker":"A"},{"text":"basically","start":1825790,"end":1826190,"confidence":0.99975586,"speaker":"A"},{"text":"binary","start":1826190,"end":1826710,"confidence":0.9995117,"speaker":"A"},{"text":"files.","start":1826710,"end":1827030,"confidence":0.99194336,"speaker":"A"},{"text":"You","start":1827030,"end":1827190,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1827190,"end":1827310,"confidence":0.99853516,"speaker":"A"},{"text":"also","start":1827310,"end":1827470,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1827470,"end":1827630,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":1827630,"end":1827910,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1828310,"end":1828670,"confidence":0.5600586,"speaker":"A"},{"text":"all","start":1828670,"end":1828910,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1828910,"end":1829070,"confidence":0.99902344,"speaker":"A"},{"text":"them.","start":1829070,"end":1829350,"confidence":0.9145508,"speaker":"A"},{"text":"You","start":1830630,"end":1830910,"confidence":0.99658203,"speaker":"A"},{"text":"can't","start":1830910,"end":1831230,"confidence":0.9586589,"speaker":"A"},{"text":"do","start":1831230,"end":1831590,"confidence":1,"speaker":"A"},{"text":"query","start":1831750,"end":1832190,"confidence":0.970459,"speaker":"A"},{"text":"notifications","start":1832190,"end":1832990,"confidence":0.99934894,"speaker":"A"},{"text":"on","start":1832990,"end":1833270,"confidence":0.98046875,"speaker":"A"},{"text":"shared.","start":1833270,"end":1833830,"confidence":0.99780273,"speaker":"A"},{"text":"Shared","start":1834470,"end":1834910,"confidence":0.9873047,"speaker":"A"},{"text":"would","start":1834910,"end":1835110,"confidence":0.5698242,"speaker":"A"},{"text":"essentially","start":1835110,"end":1835590,"confidence":0.99902344,"speaker":"A"},{"text":"work","start":1835590,"end":1835870,"confidence":1,"speaker":"A"},{"text":"like","start":1835870,"end":1836110,"confidence":0.9980469,"speaker":"A"},{"text":"private","start":1836110,"end":1836390,"confidence":0.99902344,"speaker":"A"},{"text":"essentially.","start":1836850,"end":1837410,"confidence":0.9968262,"speaker":"A"},{"text":"So","start":1837490,"end":1837890,"confidence":0.9946289,"speaker":"A"},{"text":"it's","start":1839090,"end":1839410,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":1839410,"end":1839530,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":1839530,"end":1839650,"confidence":0.9995117,"speaker":"A"},{"text":"matter","start":1839650,"end":1839810,"confidence":1,"speaker":"A"},{"text":"of","start":1839810,"end":1840130,"confidence":0.99902344,"speaker":"A"},{"text":"who.","start":1840130,"end":1840530,"confidence":0.77685547,"speaker":"A"},{"text":"Who's","start":1840530,"end":1840930,"confidence":0.9977214,"speaker":"A"},{"text":"the","start":1840930,"end":1841050,"confidence":0.99853516,"speaker":"A"},{"text":"owner","start":1841050,"end":1841370,"confidence":1,"speaker":"A"},{"text":"and","start":1841370,"end":1841570,"confidence":0.99609375,"speaker":"A"},{"text":"how","start":1841570,"end":1841810,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1841810,"end":1841970,"confidence":0.94970703,"speaker":"A"},{"text":"it","start":1841970,"end":1842090,"confidence":0.99902344,"speaker":"A"},{"text":"shared.","start":1842090,"end":1842610,"confidence":0.9968262,"speaker":"A"},{"text":"So","start":1844690,"end":1844930,"confidence":0.99658203,"speaker":"A"},{"text":"one","start":1844930,"end":1845050,"confidence":0.9794922,"speaker":"A"},{"text":"of","start":1845050,"end":1845210,"confidence":1,"speaker":"A"},{"text":"the","start":1845210,"end":1845450,"confidence":0.9995117,"speaker":"A"},{"text":"big","start":1845450,"end":1845730,"confidence":1,"speaker":"A"},{"text":"challenges","start":1845730,"end":1846370,"confidence":0.96468097,"speaker":"A"},{"text":"I","start":1846450,"end":1846730,"confidence":0.99853516,"speaker":"A"},{"text":"think","start":1846730,"end":1846890,"confidence":1,"speaker":"A"},{"text":"we've","start":1846890,"end":1847170,"confidence":0.9977214,"speaker":"A"},{"text":"all","start":1847170,"end":1847330,"confidence":0.9995117,"speaker":"A"},{"text":"faced","start":1847330,"end":1847650,"confidence":0.95825195,"speaker":"A"},{"text":"this","start":1847650,"end":1847810,"confidence":0.99072266,"speaker":"A"},{"text":"when","start":1847810,"end":1848010,"confidence":0.99609375,"speaker":"A"},{"text":"we've","start":1848010,"end":1848370,"confidence":0.98095703,"speaker":"A"},{"text":"dealt","start":1848370,"end":1848650,"confidence":0.9992676,"speaker":"A"},{"text":"with","start":1848650,"end":1848810,"confidence":1,"speaker":"A"},{"text":"certain","start":1848810,"end":1849010,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":1849010,"end":1849290,"confidence":0.99902344,"speaker":"A"},{"text":"services","start":1849290,"end":1849570,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1850530,"end":1850930,"confidence":0.98876953,"speaker":"A"},{"text":"field","start":1851410,"end":1851810,"confidence":0.9897461,"speaker":"A"},{"text":"type","start":1851970,"end":1852449,"confidence":0.810791,"speaker":"A"},{"text":"polymorphism.","start":1852449,"end":1853370,"confidence":0.9991862,"speaker":"A"},{"text":"If","start":1853370,"end":1853570,"confidence":1,"speaker":"A"},{"text":"you've","start":1853570,"end":1853730,"confidence":0.9998372,"speaker":"A"},{"text":"done","start":1853730,"end":1853890,"confidence":0.9975586,"speaker":"A"},{"text":"JSON","start":1853890,"end":1854370,"confidence":0.7998047,"speaker":"A"},{"text":"where","start":1854370,"end":1854650,"confidence":0.87939453,"speaker":"A"},{"text":"you","start":1854650,"end":1854850,"confidence":1,"speaker":"A"},{"text":"don't","start":1854850,"end":1855090,"confidence":0.9996745,"speaker":"A"},{"text":"know","start":1855090,"end":1855210,"confidence":0.99902344,"speaker":"A"},{"text":"what","start":1855210,"end":1855370,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":1855370,"end":1855730,"confidence":0.9946289,"speaker":"A"},{"text":"you're","start":1855730,"end":1855970,"confidence":1,"speaker":"A"},{"text":"getting","start":1855970,"end":1856130,"confidence":0.9995117,"speaker":"A"},{"text":"back","start":1856130,"end":1856370,"confidence":0.9980469,"speaker":"A"},{"text":"or","start":1856370,"end":1856570,"confidence":0.9980469,"speaker":"A"},{"text":"what","start":1856570,"end":1856730,"confidence":0.98876953,"speaker":"A"},{"text":"data","start":1856730,"end":1856930,"confidence":0.9980469,"speaker":"A"},{"text":"you're","start":1856930,"end":1857170,"confidence":0.9995117,"speaker":"A"},{"text":"getting","start":1857170,"end":1857370,"confidence":0.9916992,"speaker":"A"},{"text":"back,","start":1857370,"end":1857730,"confidence":0.9526367,"speaker":"A"},{"text":"this","start":1858050,"end":1858330,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":1858330,"end":1858490,"confidence":0.99902344,"speaker":"A"},{"text":"Be","start":1858490,"end":1858610,"confidence":1,"speaker":"A"},{"text":"a","start":1858610,"end":1858690,"confidence":0.9995117,"speaker":"A"},{"text":"bit","start":1858690,"end":1858850,"confidence":0.99902344,"speaker":"A"},{"text":"challenging.","start":1858850,"end":1859410,"confidence":0.9601237,"speaker":"A"},{"text":"So","start":1860530,"end":1860930,"confidence":0.9951172,"speaker":"A"},{"text":"if","start":1861730,"end":1862050,"confidence":0.6791992,"speaker":"A"},{"text":"you","start":1862050,"end":1862250,"confidence":1,"speaker":"A"},{"text":"look","start":1862250,"end":1862410,"confidence":1,"speaker":"A"},{"text":"at","start":1862410,"end":1862610,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1862610,"end":1862850,"confidence":0.9980469,"speaker":"A"},{"text":"documentation","start":1862850,"end":1863650,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":1864290,"end":1864490,"confidence":0.78466797,"speaker":"A"},{"text":"Web","start":1864490,"end":1864810,"confidence":0.9890137,"speaker":"A"},{"text":"Services","start":1864810,"end":1865090,"confidence":0.99902344,"speaker":"A"},{"text":"Reference,","start":1865090,"end":1865810,"confidence":0.9918213,"speaker":"A"},{"text":"there","start":1866850,"end":1867210,"confidence":0.9921875,"speaker":"A"},{"text":"is","start":1867210,"end":1867570,"confidence":0.99902344,"speaker":"A"},{"text":"a,","start":1867890,"end":1868290,"confidence":0.99853516,"speaker":"A"},{"text":"there's","start":1869090,"end":1869610,"confidence":0.9824219,"speaker":"A"},{"text":"a","start":1869610,"end":1869890,"confidence":0.99902344,"speaker":"A"},{"text":"page","start":1869890,"end":1870290,"confidence":0.9951172,"speaker":"A"},{"text":"called","start":1870290,"end":1870530,"confidence":0.9995117,"speaker":"A"},{"text":"types","start":1870530,"end":1870810,"confidence":0.87719727,"speaker":"A"},{"text":"and","start":1870810,"end":1870970,"confidence":0.9536133,"speaker":"A"},{"text":"dictionaries","start":1870970,"end":1871650,"confidence":0.99609375,"speaker":"A"},{"text":"and","start":1871650,"end":1872010,"confidence":0.99902344,"speaker":"A"},{"text":"there","start":1872010,"end":1872290,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":1872290,"end":1872610,"confidence":0.99609375,"speaker":"A"},{"text":"types.","start":1872610,"end":1873170,"confidence":0.9255371,"speaker":"A"},{"text":"There's","start":1874050,"end":1874410,"confidence":0.98860675,"speaker":"A"},{"text":"different","start":1874410,"end":1874610,"confidence":1,"speaker":"A"},{"text":"type","start":1874610,"end":1875010,"confidence":0.83618164,"speaker":"A"},{"text":"values","start":1875010,"end":1875530,"confidence":0.9992676,"speaker":"A"},{"text":"for","start":1875530,"end":1875690,"confidence":1,"speaker":"A"},{"text":"each","start":1875690,"end":1875930,"confidence":1,"speaker":"A"},{"text":"field.","start":1875930,"end":1876250,"confidence":1,"speaker":"A"},{"text":"If","start":1876250,"end":1876450,"confidence":1,"speaker":"A"},{"text":"you're","start":1876450,"end":1876610,"confidence":1,"speaker":"A"},{"text":"familiar","start":1876610,"end":1876890,"confidence":1,"speaker":"A"},{"text":"with","start":1876890,"end":1877050,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit,","start":1877050,"end":1877530,"confidence":0.953125,"speaker":"A"},{"text":"you've","start":1877530,"end":1877730,"confidence":0.99886066,"speaker":"A"},{"text":"seen","start":1877730,"end":1877890,"confidence":0.9995117,"speaker":"A"},{"text":"this,","start":1877890,"end":1878130,"confidence":0.9980469,"speaker":"A"},{"text":"right?","start":1878130,"end":1878450,"confidence":0.99853516,"speaker":"A"},{"text":"So","start":1879170,"end":1879570,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":1879570,"end":1879850,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":1879850,"end":1880089,"confidence":1,"speaker":"A"},{"text":"an","start":1880089,"end":1880329,"confidence":0.99853516,"speaker":"A"},{"text":"asset","start":1880329,"end":1880650,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":1880650,"end":1880850,"confidence":1,"speaker":"A"},{"text":"is","start":1880850,"end":1881050,"confidence":0.9995117,"speaker":"A"},{"text":"basically","start":1881050,"end":1881490,"confidence":1,"speaker":"A"},{"text":"a,","start":1882210,"end":1882610,"confidence":0.9838867,"speaker":"A"},{"text":"a","start":1884290,"end":1884690,"confidence":0.9995117,"speaker":"A"},{"text":"binary","start":1884690,"end":1885330,"confidence":0.9998372,"speaker":"A"},{"text":"file.","start":1885330,"end":1885810,"confidence":0.69873047,"speaker":"A"},{"text":"You","start":1886850,"end":1887170,"confidence":1,"speaker":"A"},{"text":"have","start":1887170,"end":1887490,"confidence":1,"speaker":"A"},{"text":"bytes","start":1887490,"end":1888210,"confidence":0.8411458,"speaker":"A"},{"text":"which","start":1889090,"end":1889410,"confidence":1,"speaker":"A"},{"text":"is","start":1889410,"end":1889650,"confidence":0.9995117,"speaker":"A"},{"text":"essentially","start":1889650,"end":1890130,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1890130,"end":1890450,"confidence":0.95996094,"speaker":"A"},{"text":"60","start":1890530,"end":1890930,"confidence":0.9458,"speaker":"A"},{"text":"byte","start":1891170,"end":1891650,"confidence":0.9658203,"speaker":"A"},{"text":"base","start":1891860,"end":1892100,"confidence":0.8461914,"speaker":"A"},{"text":"64","start":1892100,"end":1892580,"confidence":0.99829,"speaker":"A"},{"text":"encoded","start":1892580,"end":1893140,"confidence":0.9967448,"speaker":"A"},{"text":"string,","start":1893140,"end":1893620,"confidence":0.9970703,"speaker":"A"},{"text":"date","start":1894740,"end":1895140,"confidence":0.98095703,"speaker":"A"},{"text":"type","start":1895140,"end":1895580,"confidence":0.9716797,"speaker":"A"},{"text":"which","start":1895580,"end":1895820,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1895820,"end":1896060,"confidence":0.99658203,"speaker":"A"},{"text":"returned","start":1896060,"end":1896580,"confidence":0.98876953,"speaker":"A"},{"text":"as","start":1896580,"end":1896700,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1896700,"end":1896860,"confidence":0.9995117,"speaker":"A"},{"text":"number.","start":1896860,"end":1897140,"confidence":0.99560547,"speaker":"A"},{"text":"Double","start":1897780,"end":1898220,"confidence":0.9511719,"speaker":"A"},{"text":"is","start":1898220,"end":1898460,"confidence":0.98779297,"speaker":"A"},{"text":"returned","start":1898460,"end":1898860,"confidence":0.954834,"speaker":"A"},{"text":"as","start":1898860,"end":1899020,"confidence":0.9951172,"speaker":"A"},{"text":"a","start":1899020,"end":1899140,"confidence":0.99853516,"speaker":"A"},{"text":"number","start":1899140,"end":1899380,"confidence":0.99658203,"speaker":"A"},{"text":"because","start":1899940,"end":1900220,"confidence":0.7080078,"speaker":"A"},{"text":"These","start":1900220,"end":1900380,"confidence":0.99658203,"speaker":"A"},{"text":"are","start":1900380,"end":1900500,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":1900500,"end":1900620,"confidence":0.9995117,"speaker":"A"},{"text":"JavaScript","start":1900620,"end":1901220,"confidence":0.9517415,"speaker":"A"},{"text":"types.","start":1901220,"end":1901620,"confidence":0.76464844,"speaker":"A"},{"text":"Int","start":1902260,"end":1902660,"confidence":0.57714844,"speaker":"A"},{"text":"is","start":1902820,"end":1903220,"confidence":0.99609375,"speaker":"A"},{"text":"returned","start":1903540,"end":1904060,"confidence":0.9616699,"speaker":"A"},{"text":"as","start":1904060,"end":1904220,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":1904220,"end":1904340,"confidence":0.99902344,"speaker":"A"},{"text":"number","start":1904340,"end":1904580,"confidence":0.99609375,"speaker":"A"},{"text":"and","start":1905700,"end":1905980,"confidence":0.9946289,"speaker":"A"},{"text":"then","start":1905980,"end":1906140,"confidence":0.99902344,"speaker":"A"},{"text":"there's","start":1906140,"end":1906420,"confidence":0.85302734,"speaker":"A"},{"text":"location","start":1906420,"end":1906980,"confidence":0.99902344,"speaker":"A"},{"text":"reference","start":1907540,"end":1908260,"confidence":0.8996582,"speaker":"A"},{"text":"and","start":1909300,"end":1909620,"confidence":0.9892578,"speaker":"A"},{"text":"then","start":1909620,"end":1909940,"confidence":0.9980469,"speaker":"A"},{"text":"string","start":1910020,"end":1910500,"confidence":0.9926758,"speaker":"A"},{"text":"and","start":1910500,"end":1910740,"confidence":0.98828125,"speaker":"A"},{"text":"list.","start":1910740,"end":1911060,"confidence":0.99658203,"speaker":"A"},{"text":"And","start":1911620,"end":1912020,"confidence":0.9951172,"speaker":"A"},{"text":"how","start":1912100,"end":1912420,"confidence":0.9980469,"speaker":"A"},{"text":"would","start":1912420,"end":1912620,"confidence":0.94873047,"speaker":"A"},{"text":"you","start":1912620,"end":1912900,"confidence":0.99902344,"speaker":"A"},{"text":"like,","start":1913060,"end":1913420,"confidence":0.9946289,"speaker":"A"},{"text":"how","start":1913420,"end":1913660,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1913660,"end":1913820,"confidence":0.99658203,"speaker":"A"},{"text":"you","start":1913820,"end":1914020,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":1914020,"end":1914340,"confidence":0.99902344,"speaker":"A"},{"text":"adjacent","start":1914820,"end":1915620,"confidence":0.7462891,"speaker":"A"},{"text":"object","start":1915780,"end":1916220,"confidence":0.82470703,"speaker":"A"},{"text":"like","start":1916220,"end":1916460,"confidence":0.99902344,"speaker":"A"},{"text":"this?","start":1916460,"end":1916620,"confidence":0.99902344,"speaker":"A"},{"text":"How","start":1916620,"end":1916780,"confidence":0.9975586,"speaker":"A"},{"text":"would","start":1916780,"end":1916940,"confidence":0.99560547,"speaker":"A"},{"text":"you","start":1916940,"end":1917100,"confidence":0.9980469,"speaker":"A"},{"text":"even","start":1917100,"end":1917300,"confidence":0.9995117,"speaker":"A"},{"text":"represent","start":1917300,"end":1917620,"confidence":0.99853516,"speaker":"A"},{"text":"this","start":1917620,"end":1917900,"confidence":0.8857422,"speaker":"A"},{"text":"in","start":1917900,"end":1918060,"confidence":0.9404297,"speaker":"A"},{"text":"Swift?","start":1918060,"end":1918380,"confidence":0.9929199,"speaker":"A"},{"text":"Because","start":1918380,"end":1918580,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1918580,"end":1918740,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":1918740,"end":1918900,"confidence":0.99934894,"speaker":"A"},{"text":"know","start":1918900,"end":1918980,"confidence":0.99902344,"speaker":"A"},{"text":"what","start":1918980,"end":1919100,"confidence":0.9970703,"speaker":"A"},{"text":"type","start":1919100,"end":1919300,"confidence":0.9980469,"speaker":"A"},{"text":"you're","start":1919300,"end":1919460,"confidence":0.99820966,"speaker":"A"},{"text":"going","start":1919460,"end":1919540,"confidence":0.72802734,"speaker":"A"},{"text":"to","start":1919540,"end":1919620,"confidence":0.99902344,"speaker":"A"},{"text":"get.","start":1919620,"end":1919860,"confidence":0.9980469,"speaker":"A"},{"text":"So","start":1921350,"end":1921590,"confidence":0.9604492,"speaker":"A"},{"text":"like","start":1922790,"end":1923070,"confidence":0.99609375,"speaker":"A"},{"text":"I","start":1923070,"end":1923230,"confidence":0.9995117,"speaker":"A"},{"text":"said,","start":1923230,"end":1923390,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":1923390,"end":1923550,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":1923550,"end":1923710,"confidence":0.9975586,"speaker":"A"},{"text":"a","start":1923710,"end":1923830,"confidence":0.9980469,"speaker":"A"},{"text":"work","start":1923830,"end":1923950,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":1923950,"end":1924110,"confidence":0.99902344,"speaker":"A"},{"text":"progress.","start":1924110,"end":1924510,"confidence":0.99975586,"speaker":"A"},{"text":"Sorry.","start":1924510,"end":1924950,"confidence":0.9889323,"speaker":"A"},{"text":"So","start":1925830,"end":1926150,"confidence":0.94628906,"speaker":"A"},{"text":"what","start":1926150,"end":1926350,"confidence":0.99609375,"speaker":"A"},{"text":"I","start":1926350,"end":1926550,"confidence":0.99853516,"speaker":"A"},{"text":"do,","start":1926550,"end":1926870,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":1927190,"end":1927430,"confidence":0.99853516,"speaker":"A"},{"text":"don't","start":1927430,"end":1927590,"confidence":0.9785156,"speaker":"A"},{"text":"know","start":1927590,"end":1927670,"confidence":0.9975586,"speaker":"A"},{"text":"how","start":1927670,"end":1927790,"confidence":0.99902344,"speaker":"A"},{"text":"much","start":1927790,"end":1927950,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":1927950,"end":1928110,"confidence":0.99853516,"speaker":"A"},{"text":"can","start":1928110,"end":1928270,"confidence":0.7426758,"speaker":"A"},{"text":"see","start":1928270,"end":1928430,"confidence":0.9995117,"speaker":"A"},{"text":"this.","start":1928430,"end":1928710,"confidence":0.9951172,"speaker":"A"},{"text":"I'm","start":1929110,"end":1929430,"confidence":0.99886066,"speaker":"A"},{"text":"going","start":1929430,"end":1929550,"confidence":0.71240234,"speaker":"A"},{"text":"to","start":1929550,"end":1929710,"confidence":0.99902344,"speaker":"A"},{"text":"actually","start":1929710,"end":1929910,"confidence":0.9975586,"speaker":"A"},{"text":"move","start":1929910,"end":1930150,"confidence":0.9995117,"speaker":"A"},{"text":"over","start":1930150,"end":1930430,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":1930430,"end":1930790,"confidence":0.99853516,"speaker":"A"},{"text":"my","start":1932470,"end":1932870,"confidence":0.99902344,"speaker":"A"},{"text":"documentation","start":1932950,"end":1933910,"confidence":0.99990237,"speaker":"A"},{"text":"here","start":1933910,"end":1934310,"confidence":0.99609375,"speaker":"A"},{"text":"at","start":1935270,"end":1935550,"confidence":0.9951172,"speaker":"A"},{"text":"this","start":1935550,"end":1935710,"confidence":1,"speaker":"A"},{"text":"point.","start":1935710,"end":1935990,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":1936150,"end":1936550,"confidence":0.9145508,"speaker":"A"},{"text":"how","start":1938310,"end":1938590,"confidence":0.99853516,"speaker":"A"},{"text":"are","start":1938590,"end":1938710,"confidence":0.9394531,"speaker":"A"},{"text":"we","start":1938710,"end":1938830,"confidence":0.42895508,"speaker":"A"},{"text":"doing","start":1938830,"end":1938990,"confidence":0.9980469,"speaker":"A"},{"text":"on","start":1938990,"end":1939190,"confidence":0.99853516,"speaker":"A"},{"text":"time?","start":1939190,"end":1939510,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":1939510,"end":1939790,"confidence":0.7001953,"speaker":"A"},{"text":"good?","start":1939790,"end":1940070,"confidence":0.98876953,"speaker":"A"},{"text":"Yeah,","start":1942550,"end":1942870,"confidence":0.9842122,"speaker":"B"},{"text":"I","start":1942870,"end":1942990,"confidence":0.59228516,"speaker":"B"},{"text":"think,","start":1942990,"end":1943190,"confidence":0.9770508,"speaker":"B"},{"text":"I","start":1943190,"end":1943350,"confidence":0.96240234,"speaker":"B"},{"text":"think","start":1943350,"end":1943470,"confidence":0.9975586,"speaker":"B"},{"text":"we're","start":1943470,"end":1943670,"confidence":0.99902344,"speaker":"B"},{"text":"doing","start":1943670,"end":1943790,"confidence":0.9980469,"speaker":"B"},{"text":"good.","start":1943790,"end":1944070,"confidence":0.9951172,"speaker":"B"},{"text":"Okay,","start":1944870,"end":1945310,"confidence":0.94189453,"speaker":"A"},{"text":"cool.","start":1945310,"end":1945590,"confidence":0.99780273,"speaker":"A"},{"text":"Any,","start":1945590,"end":1945910,"confidence":0.90234375,"speaker":"A"},{"text":"do","start":1946560,"end":1946640,"confidence":0.70996094,"speaker":"A"},{"text":"you","start":1946640,"end":1946760,"confidence":0.9946289,"speaker":"A"},{"text":"want","start":1946760,"end":1946880,"confidence":0.9321289,"speaker":"A"},{"text":"to","start":1946880,"end":1946960,"confidence":0.9980469,"speaker":"A"},{"text":"ask","start":1946960,"end":1947120,"confidence":0.9995117,"speaker":"A"},{"text":"questions?","start":1947120,"end":1947680,"confidence":0.99975586,"speaker":"A"},{"text":"I","start":1949680,"end":1949960,"confidence":0.9975586,"speaker":"B"},{"text":"don't","start":1949960,"end":1950240,"confidence":0.9991862,"speaker":"B"},{"text":"have","start":1950240,"end":1950480,"confidence":0.9995117,"speaker":"B"},{"text":"anything","start":1950480,"end":1950960,"confidence":0.99975586,"speaker":"B"},{"text":"right","start":1951440,"end":1951800,"confidence":0.99902344,"speaker":"B"},{"text":"now.","start":1951800,"end":1952160,"confidence":0.99853516,"speaker":"B"},{"text":"Same","start":1953760,"end":1954160,"confidence":0.98291016,"speaker":"C"},{"text":"nothing","start":1954240,"end":1954600,"confidence":0.99975586,"speaker":"C"},{"text":"right","start":1954600,"end":1954800,"confidence":0.9995117,"speaker":"C"},{"text":"now.","start":1954800,"end":1955040,"confidence":0.9995117,"speaker":"C"},{"text":"But","start":1955040,"end":1955240,"confidence":0.9980469,"speaker":"C"},{"text":"this","start":1955240,"end":1955440,"confidence":0.99853516,"speaker":"C"},{"text":"seems","start":1955440,"end":1955880,"confidence":0.99975586,"speaker":"C"},{"text":"applicable","start":1955880,"end":1956560,"confidence":0.99975586,"speaker":"C"},{"text":"to","start":1956560,"end":1956960,"confidence":0.9995117,"speaker":"C"},{"text":"things","start":1957280,"end":1957600,"confidence":1,"speaker":"C"},{"text":"I'll","start":1957600,"end":1957880,"confidence":0.98779297,"speaker":"C"},{"text":"be","start":1957880,"end":1958000,"confidence":0.9995117,"speaker":"C"},{"text":"doing","start":1958000,"end":1958200,"confidence":0.9995117,"speaker":"C"},{"text":"coming","start":1958200,"end":1958480,"confidence":0.99853516,"speaker":"C"},{"text":"up.","start":1958480,"end":1958800,"confidence":0.99609375,"speaker":"C"},{"text":"Okay,","start":1959360,"end":1960000,"confidence":0.88964844,"speaker":"A"},{"text":"cool.","start":1960000,"end":1960480,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":1963200,"end":1963600,"confidence":0.8515625,"speaker":"A"},{"text":"we","start":1964480,"end":1964760,"confidence":0.9838867,"speaker":"A"},{"text":"have","start":1964760,"end":1964960,"confidence":0.59765625,"speaker":"A"},{"text":"set","start":1964960,"end":1965200,"confidence":0.99902344,"speaker":"A"},{"text":"up","start":1965200,"end":1965520,"confidence":0.9716797,"speaker":"A"},{"text":"in","start":1965920,"end":1966280,"confidence":0.85595703,"speaker":"A"},{"text":"the","start":1966280,"end":1966640,"confidence":0.98291016,"speaker":"A"},{"text":"open.","start":1966800,"end":1967200,"confidence":0.9916992,"speaker":"A"},{"text":"So","start":1967200,"end":1967440,"confidence":0.93896484,"speaker":"A"},{"text":"we","start":1967440,"end":1967520,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":1967520,"end":1967640,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":1967640,"end":1967760,"confidence":0.9116211,"speaker":"A"},{"text":"open","start":1967760,"end":1967960,"confidence":0.99853516,"speaker":"A"},{"text":"API","start":1967960,"end":1968480,"confidence":0.9958496,"speaker":"A"},{"text":"YAML","start":1968480,"end":1968920,"confidence":0.9547526,"speaker":"A"},{"text":"file","start":1968920,"end":1969360,"confidence":0.99731445,"speaker":"A"},{"text":"that","start":1969760,"end":1970040,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":1970040,"end":1970240,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":1970240,"end":1970400,"confidence":0.99853516,"speaker":"A"},{"text":"pull","start":1970400,"end":1970560,"confidence":0.99975586,"speaker":"A"},{"text":"up","start":1970560,"end":1970680,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":1970680,"end":1970880,"confidence":0.9970703,"speaker":"A"},{"text":"Miskit,","start":1970880,"end":1971520,"confidence":0.98657227,"speaker":"A"},{"text":"which","start":1972250,"end":1972370,"confidence":0.9975586,"speaker":"A"},{"text":"is","start":1972370,"end":1972650,"confidence":0.99902344,"speaker":"A"},{"text":"basically","start":1972730,"end":1973370,"confidence":0.99975586,"speaker":"A"},{"text":"every","start":1973370,"end":1973770,"confidence":0.99365234,"speaker":"A"},{"text":"like","start":1973770,"end":1974170,"confidence":0.98828125,"speaker":"A"},{"text":"the","start":1975050,"end":1975370,"confidence":0.99902344,"speaker":"A"},{"text":"documentation","start":1975370,"end":1976170,"confidence":0.99912107,"speaker":"A"},{"text":"converted","start":1976330,"end":1977010,"confidence":0.9996745,"speaker":"A"},{"text":"to","start":1977010,"end":1977210,"confidence":0.9975586,"speaker":"A"},{"text":"YAML.","start":1977210,"end":1977850,"confidence":0.71435547,"speaker":"A"},{"text":"And","start":1978410,"end":1978770,"confidence":0.99072266,"speaker":"A"},{"text":"so","start":1978770,"end":1978970,"confidence":1,"speaker":"A"},{"text":"what","start":1978970,"end":1979090,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":1979090,"end":1979290,"confidence":1,"speaker":"A"},{"text":"do","start":1979290,"end":1979570,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":1979570,"end":1979930,"confidence":0.6928711,"speaker":"A"},{"text":"you","start":1980090,"end":1980410,"confidence":1,"speaker":"A"},{"text":"can","start":1980410,"end":1980690,"confidence":1,"speaker":"A"},{"text":"set","start":1980690,"end":1980930,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":1980930,"end":1981210,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":1982490,"end":1982770,"confidence":0.98095703,"speaker":"A"},{"text":"the","start":1982770,"end":1982930,"confidence":0.9951172,"speaker":"A"},{"text":"YAML","start":1982930,"end":1983250,"confidence":0.8038737,"speaker":"A"},{"text":"the","start":1983250,"end":1983410,"confidence":0.97753906,"speaker":"A"},{"text":"field","start":1983410,"end":1983690,"confidence":0.9980469,"speaker":"A"},{"text":"value","start":1983770,"end":1984130,"confidence":1,"speaker":"A"},{"text":"requests","start":1984130,"end":1984690,"confidence":0.8439128,"speaker":"A"},{"text":"and","start":1984690,"end":1984810,"confidence":0.9970703,"speaker":"A"},{"text":"they","start":1984810,"end":1984930,"confidence":1,"speaker":"A"},{"text":"have","start":1984930,"end":1985090,"confidence":1,"speaker":"A"},{"text":"an","start":1985090,"end":1985290,"confidence":0.9633789,"speaker":"A"},{"text":"enum","start":1985290,"end":1985770,"confidence":0.8808594,"speaker":"A"},{"text":"type","start":1985770,"end":1986090,"confidence":0.8652344,"speaker":"A"},{"text":"essentially","start":1986090,"end":1986650,"confidence":0.94311523,"speaker":"A"},{"text":"for,","start":1987930,"end":1988330,"confidence":0.96875,"speaker":"A"},{"text":"for","start":1992090,"end":1992450,"confidence":0.9995117,"speaker":"A"},{"text":"open","start":1992450,"end":1992810,"confidence":0.9995117,"speaker":"A"},{"text":"API.","start":1992970,"end":1993610,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":1993690,"end":1994090,"confidence":0.98583984,"speaker":"A"},{"text":"and","start":1994970,"end":1995250,"confidence":0.9350586,"speaker":"A"},{"text":"then,","start":1995250,"end":1995490,"confidence":0.39233398,"speaker":"A"},{"text":"so","start":1995490,"end":1995770,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":1995770,"end":1996010,"confidence":0.99902344,"speaker":"A"},{"text":"has,","start":1996010,"end":1996330,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":1996330,"end":1996570,"confidence":0.6645508,"speaker":"A"},{"text":"know,","start":1996570,"end":1996690,"confidence":0.97998047,"speaker":"A"},{"text":"it","start":1996690,"end":1996810,"confidence":0.9975586,"speaker":"A"},{"text":"could","start":1996810,"end":1996930,"confidence":0.9838867,"speaker":"A"},{"text":"be","start":1996930,"end":1997090,"confidence":1,"speaker":"A"},{"text":"one","start":1997090,"end":1997210,"confidence":0.99853516,"speaker":"A"},{"text":"of","start":1997210,"end":1997410,"confidence":0.99902344,"speaker":"A"},{"text":"either","start":1997410,"end":1997770,"confidence":0.9968262,"speaker":"A"},{"text":"any","start":1997770,"end":1998010,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":1998010,"end":1998170,"confidence":1,"speaker":"A"},{"text":"these","start":1998170,"end":1998370,"confidence":0.99902344,"speaker":"A"},{"text":"types","start":1998370,"end":1998810,"confidence":0.9453125,"speaker":"A"},{"text":"of.","start":1998860,"end":1999020,"confidence":0.5004883,"speaker":"A"},{"text":"And","start":2000050,"end":2000210,"confidence":0.97216797,"speaker":"A"},{"text":"then","start":2000210,"end":2000530,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":2000850,"end":2001210,"confidence":0.99560547,"speaker":"A"},{"text":"an","start":2001210,"end":2001370,"confidence":0.76220703,"speaker":"A"},{"text":"enum","start":2001370,"end":2001850,"confidence":0.92211914,"speaker":"A"},{"text":"in","start":2001850,"end":2002090,"confidence":0.9995117,"speaker":"A"},{"text":"case","start":2002090,"end":2002290,"confidence":1,"speaker":"A"},{"text":"you","start":2002290,"end":2002530,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2002530,"end":2002730,"confidence":1,"speaker":"A"},{"text":"a","start":2002730,"end":2002890,"confidence":0.99902344,"speaker":"A"},{"text":"list.","start":2002890,"end":2003170,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2004050,"end":2004450,"confidence":0.99560547,"speaker":"A"},{"text":"if","start":2005250,"end":2005570,"confidence":0.9980469,"speaker":"A"},{"text":"you","start":2005570,"end":2005770,"confidence":1,"speaker":"A"},{"text":"have","start":2005770,"end":2005970,"confidence":1,"speaker":"A"},{"text":"a","start":2005970,"end":2006210,"confidence":0.99902344,"speaker":"A"},{"text":"list","start":2006210,"end":2006530,"confidence":0.9995117,"speaker":"A"},{"text":"value","start":2006850,"end":2007250,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":2007330,"end":2007890,"confidence":0.99780273,"speaker":"A"},{"text":"there","start":2008530,"end":2008850,"confidence":1,"speaker":"A"},{"text":"is","start":2008850,"end":2009090,"confidence":1,"speaker":"A"},{"text":"an","start":2009090,"end":2009290,"confidence":0.9995117,"speaker":"A"},{"text":"extra","start":2009290,"end":2009690,"confidence":0.99975586,"speaker":"A"},{"text":"property","start":2009690,"end":2010290,"confidence":0.9995117,"speaker":"A"},{"text":"called","start":2010290,"end":2010690,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":2011010,"end":2011450,"confidence":0.81103516,"speaker":"A"},{"text":"and","start":2011450,"end":2011690,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":2011690,"end":2011850,"confidence":0.99365234,"speaker":"A"},{"text":"that","start":2011850,"end":2012010,"confidence":0.9995117,"speaker":"A"},{"text":"will","start":2012010,"end":2012210,"confidence":0.9995117,"speaker":"A"},{"text":"tell","start":2012210,"end":2012410,"confidence":1,"speaker":"A"},{"text":"you","start":2012410,"end":2012570,"confidence":1,"speaker":"A"},{"text":"what","start":2012570,"end":2012810,"confidence":0.59277344,"speaker":"A"},{"text":"type","start":2012810,"end":2013250,"confidence":0.8652344,"speaker":"A"},{"text":"the.","start":2013410,"end":2013810,"confidence":0.98876953,"speaker":"A"},{"text":"The","start":2014450,"end":2014730,"confidence":0.99853516,"speaker":"A"},{"text":"list","start":2014730,"end":2015010,"confidence":0.9995117,"speaker":"A"},{"text":"is.","start":2015010,"end":2015329,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":2015329,"end":2015570,"confidence":0.99365234,"speaker":"A"},{"text":"it's","start":2015570,"end":2016050,"confidence":0.99397784,"speaker":"A"},{"text":"homo","start":2016530,"end":2017250,"confidence":0.8297526,"speaker":"A"},{"text":"homomorphic.","start":2017250,"end":2018450,"confidence":0.99763995,"speaker":"A"},{"text":"It's","start":2018690,"end":2019050,"confidence":0.9720052,"speaker":"A"},{"text":"all","start":2019050,"end":2019210,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":2019210,"end":2019330,"confidence":0.9995117,"speaker":"A"},{"text":"same","start":2019330,"end":2019570,"confidence":0.99902344,"speaker":"A"},{"text":"list","start":2019890,"end":2020210,"confidence":0.97314453,"speaker":"A"},{"text":"type.","start":2020210,"end":2020490,"confidence":0.9848633,"speaker":"A"},{"text":"You","start":2020490,"end":2020610,"confidence":0.9995117,"speaker":"A"},{"text":"can't","start":2020610,"end":2020810,"confidence":0.98567706,"speaker":"A"},{"text":"have","start":2020810,"end":2021010,"confidence":1,"speaker":"A"},{"text":"lists","start":2021010,"end":2021330,"confidence":0.9987793,"speaker":"A"},{"text":"of","start":2021330,"end":2021450,"confidence":0.9995117,"speaker":"A"},{"text":"different","start":2021450,"end":2021690,"confidence":1,"speaker":"A"},{"text":"types.","start":2021690,"end":2022210,"confidence":0.92578125,"speaker":"A"},{"text":"And","start":2024050,"end":2024450,"confidence":0.95751953,"speaker":"A"},{"text":"then","start":2024610,"end":2025010,"confidence":0.9038086,"speaker":"A"},{"text":"we","start":2026030,"end":2026190,"confidence":0.9941406,"speaker":"A"},{"text":"have","start":2026190,"end":2026470,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":2026470,"end":2026830,"confidence":0.99902344,"speaker":"A"},{"text":"again","start":2028830,"end":2029230,"confidence":0.99853516,"speaker":"A"},{"text":"field","start":2029230,"end":2029590,"confidence":0.9404297,"speaker":"A"},{"text":"value.","start":2029590,"end":2029950,"confidence":0.99902344,"speaker":"A"},{"text":"Sometimes","start":2031390,"end":2031910,"confidence":0.99886066,"speaker":"A"},{"text":"the","start":2031910,"end":2032070,"confidence":0.98876953,"speaker":"A"},{"text":"type","start":2032070,"end":2032310,"confidence":0.9086914,"speaker":"A"},{"text":"is","start":2032310,"end":2032470,"confidence":0.99853516,"speaker":"A"},{"text":"available,","start":2032470,"end":2032750,"confidence":0.9995117,"speaker":"A"},{"text":"sometimes","start":2032910,"end":2033430,"confidence":0.9996745,"speaker":"A"},{"text":"it's","start":2033430,"end":2033750,"confidence":0.99886066,"speaker":"A"},{"text":"not.","start":2033750,"end":2034030,"confidence":0.9995117,"speaker":"A"},{"text":"But","start":2034590,"end":2034910,"confidence":0.99658203,"speaker":"A"},{"text":"basically","start":2034910,"end":2035390,"confidence":0.99975586,"speaker":"A"},{"text":"we","start":2035390,"end":2035670,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2035670,"end":2035910,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2035910,"end":2036150,"confidence":1,"speaker":"A"},{"text":"the","start":2036150,"end":2036310,"confidence":0.9995117,"speaker":"A"},{"text":"different","start":2036310,"end":2036590,"confidence":0.9995117,"speaker":"A"},{"text":"value","start":2036750,"end":2037150,"confidence":0.99902344,"speaker":"A"},{"text":"types","start":2037230,"end":2037710,"confidence":0.99975586,"speaker":"A"},{"text":"available","start":2037710,"end":2038030,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2038190,"end":2038470,"confidence":1,"speaker":"A"},{"text":"us","start":2038470,"end":2038750,"confidence":1,"speaker":"A"},{"text":"in","start":2038830,"end":2039110,"confidence":0.97802734,"speaker":"A"},{"text":"a","start":2039110,"end":2039270,"confidence":0.96728516,"speaker":"A"},{"text":"CK","start":2039270,"end":2039630,"confidence":0.9001465,"speaker":"A"},{"text":"value.","start":2039630,"end":2039950,"confidence":0.9091797,"speaker":"A"},{"text":"And","start":2041950,"end":2042230,"confidence":0.9848633,"speaker":"A"},{"text":"then","start":2042230,"end":2042510,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":2042990,"end":2043310,"confidence":0.99853516,"speaker":"A"},{"text":"is.","start":2043310,"end":2043550,"confidence":0.99902344,"speaker":"A"},{"text":"Then","start":2043550,"end":2043870,"confidence":0.9848633,"speaker":"A"},{"text":"the","start":2044110,"end":2044430,"confidence":0.98828125,"speaker":"A"},{"text":"Open","start":2044430,"end":2044750,"confidence":0.9946289,"speaker":"A"},{"text":"API","start":2045150,"end":2045670,"confidence":0.99780273,"speaker":"A"},{"text":"generator","start":2045670,"end":2046190,"confidence":0.97143555,"speaker":"A"},{"text":"essentially","start":2046190,"end":2046870,"confidence":0.99902344,"speaker":"A"},{"text":"builds","start":2046870,"end":2047310,"confidence":0.9782715,"speaker":"A"},{"text":"this","start":2047310,"end":2047470,"confidence":0.9926758,"speaker":"A"},{"text":"for","start":2047470,"end":2047670,"confidence":0.9838867,"speaker":"A"},{"text":"me","start":2047670,"end":2047950,"confidence":0.99853516,"speaker":"A"},{"text":"which","start":2048510,"end":2048830,"confidence":0.9980469,"speaker":"A"},{"text":"is.","start":2048830,"end":2049150,"confidence":0.9873047,"speaker":"A"},{"text":"Has","start":2049710,"end":2049990,"confidence":0.9980469,"speaker":"A"},{"text":"an","start":2049990,"end":2050150,"confidence":0.47924805,"speaker":"A"},{"text":"enum","start":2050150,"end":2050670,"confidence":0.7680664,"speaker":"A"},{"text":"and","start":2050830,"end":2051110,"confidence":0.9902344,"speaker":"A"},{"text":"a","start":2051110,"end":2051270,"confidence":0.9863281,"speaker":"A"},{"text":"struck","start":2051270,"end":2051510,"confidence":0.7644043,"speaker":"A"},{"text":"for","start":2051510,"end":2051670,"confidence":0.5751953,"speaker":"A"},{"text":"field","start":2051670,"end":2051950,"confidence":0.7363281,"speaker":"A"},{"text":"field","start":2052110,"end":2052510,"confidence":1,"speaker":"A"},{"text":"value","start":2052670,"end":2053070,"confidence":0.99902344,"speaker":"A"},{"text":"request","start":2053070,"end":2053630,"confidence":0.7783203,"speaker":"A"},{"text":"and","start":2055329,"end":2055449,"confidence":0.9321289,"speaker":"A"},{"text":"then","start":2055449,"end":2055609,"confidence":0.9946289,"speaker":"A"},{"text":"it","start":2055609,"end":2055769,"confidence":1,"speaker":"A"},{"text":"does","start":2055769,"end":2055929,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2055929,"end":2056089,"confidence":0.9941406,"speaker":"A"},{"text":"the","start":2056089,"end":2056249,"confidence":0.9946289,"speaker":"A"},{"text":"decoding","start":2056249,"end":2056769,"confidence":0.99886066,"speaker":"A"},{"text":"for","start":2056769,"end":2056969,"confidence":0.99902344,"speaker":"A"},{"text":"me.","start":2056969,"end":2057249,"confidence":1,"speaker":"A"},{"text":"Thankfully","start":2057249,"end":2057849,"confidence":0.99523926,"speaker":"A"},{"text":"I","start":2057849,"end":2058089,"confidence":0.99560547,"speaker":"A"},{"text":"didn't","start":2058089,"end":2058289,"confidence":0.95670575,"speaker":"A"},{"text":"have","start":2058289,"end":2058369,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2058369,"end":2058449,"confidence":0.9980469,"speaker":"A"},{"text":"do","start":2058449,"end":2058569,"confidence":0.91845703,"speaker":"A"},{"text":"any","start":2058569,"end":2058769,"confidence":1,"speaker":"A"},{"text":"of","start":2058769,"end":2058929,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":2058929,"end":2059169,"confidence":0.9975586,"speaker":"A"},{"text":"And","start":2063089,"end":2063369,"confidence":0.97021484,"speaker":"A"},{"text":"then","start":2063369,"end":2063649,"confidence":0.99658203,"speaker":"A"},{"text":"yeah,","start":2065409,"end":2065809,"confidence":0.94091797,"speaker":"A"},{"text":"I","start":2065809,"end":2066009,"confidence":0.99902344,"speaker":"A"},{"text":"just","start":2066009,"end":2066169,"confidence":0.99902344,"speaker":"A"},{"text":"wanted","start":2066169,"end":2066409,"confidence":0.99780273,"speaker":"A"},{"text":"to","start":2066409,"end":2066569,"confidence":0.99902344,"speaker":"A"},{"text":"cover","start":2066569,"end":2066769,"confidence":1,"speaker":"A"},{"text":"that","start":2066769,"end":2067009,"confidence":0.9995117,"speaker":"A"},{"text":"piece","start":2067009,"end":2067409,"confidence":0.9667969,"speaker":"A"},{"text":"where","start":2067569,"end":2067929,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2067929,"end":2068249,"confidence":0.9995117,"speaker":"A"},{"text":"show","start":2068249,"end":2068609,"confidence":0.99902344,"speaker":"A"},{"text":"how","start":2068929,"end":2069249,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2069249,"end":2069449,"confidence":1,"speaker":"A"},{"text":"deal","start":2069449,"end":2069609,"confidence":1,"speaker":"A"},{"text":"with","start":2069609,"end":2069888,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":2069888,"end":2070209,"confidence":0.99072266,"speaker":"A"},{"text":"kind","start":2070209,"end":2070369,"confidence":0.98876953,"speaker":"A"},{"text":"of","start":2070369,"end":2070529,"confidence":0.5283203,"speaker":"A"},{"text":"like","start":2070529,"end":2070729,"confidence":0.984375,"speaker":"A"},{"text":"polymorphic","start":2070729,"end":2071969,"confidence":0.9777832,"speaker":"A"},{"text":"types","start":2071969,"end":2072529,"confidence":0.76416016,"speaker":"A"},{"text":"and","start":2073249,"end":2073529,"confidence":0.99658203,"speaker":"A"},{"text":"how","start":2073529,"end":2073729,"confidence":0.9995117,"speaker":"A"},{"text":"those","start":2073729,"end":2073969,"confidence":0.99902344,"speaker":"A"},{"text":"work.","start":2073969,"end":2074289,"confidence":0.99853516,"speaker":"A"},{"text":"The","start":2075329,"end":2075569,"confidence":0.9746094,"speaker":"A"},{"text":"next","start":2075569,"end":2075729,"confidence":0.9902344,"speaker":"A"},{"text":"thing","start":2075729,"end":2075889,"confidence":0.9692383,"speaker":"A"},{"text":"I","start":2075889,"end":2075969,"confidence":0.89208984,"speaker":"A"},{"text":"want","start":2075969,"end":2076089,"confidence":0.79052734,"speaker":"A"},{"text":"to","start":2076089,"end":2076209,"confidence":0.99902344,"speaker":"A"},{"text":"cover","start":2076209,"end":2076409,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2076409,"end":2076689,"confidence":0.99853516,"speaker":"A"},{"text":"error","start":2076689,"end":2077009,"confidence":0.914917,"speaker":"A"},{"text":"handling.","start":2077009,"end":2077489,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":2079249,"end":2079529,"confidence":0.99121094,"speaker":"A"},{"text":"if","start":2079529,"end":2079729,"confidence":0.6791992,"speaker":"A"},{"text":"you","start":2079729,"end":2079929,"confidence":1,"speaker":"A"},{"text":"look","start":2079929,"end":2080049,"confidence":1,"speaker":"A"},{"text":"at","start":2080049,"end":2080169,"confidence":1,"speaker":"A"},{"text":"the","start":2080169,"end":2080289,"confidence":1,"speaker":"A"},{"text":"documentation","start":2080289,"end":2081009,"confidence":0.9964844,"speaker":"A"},{"text":"gives","start":2081569,"end":2081969,"confidence":0.9904785,"speaker":"A"},{"text":"you.","start":2081969,"end":2082209,"confidence":0.99658203,"speaker":"A"},{"text":"If","start":2083390,"end":2083510,"confidence":0.98876953,"speaker":"A"},{"text":"you","start":2083510,"end":2083630,"confidence":0.9975586,"speaker":"A"},{"text":"get","start":2083630,"end":2083750,"confidence":0.97509766,"speaker":"A"},{"text":"an","start":2083750,"end":2083910,"confidence":0.9604492,"speaker":"A"},{"text":"error","start":2083910,"end":2084270,"confidence":0.8522949,"speaker":"A"},{"text":"we","start":2085150,"end":2085430,"confidence":0.99121094,"speaker":"A"},{"text":"get","start":2085430,"end":2085630,"confidence":0.71777344,"speaker":"A"},{"text":"something","start":2085630,"end":2085870,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":2085870,"end":2086070,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2086070,"end":2086350,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2088030,"end":2088350,"confidence":0.9238281,"speaker":"A"},{"text":"then","start":2088350,"end":2088630,"confidence":0.9921875,"speaker":"A"},{"text":"that","start":2088630,"end":2088910,"confidence":0.90283203,"speaker":"A"},{"text":"will","start":2088910,"end":2089150,"confidence":0.7714844,"speaker":"A"},{"text":"show","start":2089150,"end":2089350,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2089350,"end":2089630,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":2089870,"end":2090150,"confidence":0.7524414,"speaker":"A"},{"text":"the.","start":2090150,"end":2090350,"confidence":0.80615234,"speaker":"A"},{"text":"In","start":2090350,"end":2090590,"confidence":0.98876953,"speaker":"A"},{"text":"the","start":2090590,"end":2090750,"confidence":0.9995117,"speaker":"A"},{"text":"table","start":2090750,"end":2091070,"confidence":0.9995117,"speaker":"A"},{"text":"actually","start":2091070,"end":2091390,"confidence":0.99853516,"speaker":"A"},{"text":"shows","start":2091390,"end":2091710,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":2091710,"end":2091830,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":2091830,"end":2092030,"confidence":0.9995117,"speaker":"A"},{"text":"each","start":2092030,"end":2092350,"confidence":0.9995117,"speaker":"A"},{"text":"error","start":2092830,"end":2093270,"confidence":0.87854004,"speaker":"A"},{"text":"means.","start":2093270,"end":2093630,"confidence":0.99853516,"speaker":"A"},{"text":"So","start":2094830,"end":2095230,"confidence":0.9707031,"speaker":"A"},{"text":"again","start":2095230,"end":2095630,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2095710,"end":2095990,"confidence":1,"speaker":"A"},{"text":"do","start":2095990,"end":2096150,"confidence":0.9980469,"speaker":"A"},{"text":"like","start":2096150,"end":2096270,"confidence":0.9892578,"speaker":"A"},{"text":"an","start":2096270,"end":2096430,"confidence":0.9868164,"speaker":"A"},{"text":"enum","start":2096430,"end":2096990,"confidence":0.9489746,"speaker":"A"},{"text":"in","start":2097150,"end":2097470,"confidence":0.54541016,"speaker":"A"},{"text":"YAML.","start":2097470,"end":2098110,"confidence":0.94954425,"speaker":"A"},{"text":"It's","start":2098830,"end":2099190,"confidence":0.99853516,"speaker":"A"},{"text":"basically","start":2099190,"end":2099550,"confidence":0.99975586,"speaker":"A"},{"text":"a","start":2099550,"end":2099750,"confidence":0.9970703,"speaker":"A"},{"text":"string","start":2099750,"end":2100110,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2100110,"end":2100310,"confidence":0.99658203,"speaker":"A"},{"text":"then","start":2100310,"end":2100430,"confidence":0.9746094,"speaker":"A"},{"text":"we","start":2100430,"end":2100550,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2100550,"end":2100710,"confidence":0.9995117,"speaker":"A"},{"text":"everything","start":2100710,"end":2100910,"confidence":0.9995117,"speaker":"A"},{"text":"else","start":2100910,"end":2101190,"confidence":0.99975586,"speaker":"A"},{"text":"be","start":2101190,"end":2101350,"confidence":0.98046875,"speaker":"A"},{"text":"a","start":2101350,"end":2101510,"confidence":0.99853516,"speaker":"A"},{"text":"string.","start":2101510,"end":2101950,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":2102590,"end":2102870,"confidence":0.96240234,"speaker":"A"},{"text":"then","start":2102870,"end":2103150,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2103310,"end":2103590,"confidence":0.9946289,"speaker":"A"},{"text":"open","start":2103590,"end":2103790,"confidence":0.9946289,"speaker":"A"},{"text":"API","start":2103790,"end":2104270,"confidence":0.95581055,"speaker":"A"},{"text":"generator","start":2104270,"end":2104790,"confidence":0.998291,"speaker":"A"},{"text":"will","start":2104790,"end":2105030,"confidence":0.9975586,"speaker":"A"},{"text":"automatically","start":2105030,"end":2105590,"confidence":0.8905029,"speaker":"A"},{"text":"generate","start":2105590,"end":2106110,"confidence":1,"speaker":"A"},{"text":"this","start":2106110,"end":2106430,"confidence":0.9970703,"speaker":"A"},{"text":"which","start":2107710,"end":2108110,"confidence":0.9975586,"speaker":"A"},{"text":"gives","start":2108110,"end":2108510,"confidence":0.9970703,"speaker":"A"},{"text":"us","start":2108510,"end":2108630,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":2108630,"end":2108910,"confidence":0.53759766,"speaker":"A"},{"text":"server","start":2109500,"end":2109860,"confidence":0.9980469,"speaker":"A"},{"text":"error","start":2109860,"end":2110140,"confidence":0.986084,"speaker":"A"},{"text":"code","start":2110140,"end":2110500,"confidence":0.9977214,"speaker":"A"},{"text":"and","start":2110500,"end":2110740,"confidence":0.9145508,"speaker":"A"},{"text":"the","start":2110740,"end":2110980,"confidence":0.95751953,"speaker":"A"},{"text":"error","start":2110980,"end":2111220,"confidence":0.9855957,"speaker":"A"},{"text":"response.","start":2111220,"end":2111820,"confidence":0.89868164,"speaker":"A"},{"text":"It'll","start":2112380,"end":2112820,"confidence":0.9863281,"speaker":"A"},{"text":"also","start":2112820,"end":2113060,"confidence":1,"speaker":"A"},{"text":"do","start":2113060,"end":2113300,"confidence":1,"speaker":"A"},{"text":"all","start":2113300,"end":2113460,"confidence":1,"speaker":"A"},{"text":"this","start":2113460,"end":2113660,"confidence":0.61621094,"speaker":"A"},{"text":"stuff","start":2113660,"end":2113980,"confidence":1,"speaker":"A"},{"text":"here,","start":2113980,"end":2114260,"confidence":1,"speaker":"A"},{"text":"which","start":2114260,"end":2114580,"confidence":0.9399414,"speaker":"A"},{"text":"is","start":2114580,"end":2114820,"confidence":0.99658203,"speaker":"A"},{"text":"really","start":2114820,"end":2115060,"confidence":0.74316406,"speaker":"A"},{"text":"nice.","start":2115060,"end":2115500,"confidence":1,"speaker":"A"},{"text":"And","start":2117980,"end":2118260,"confidence":0.9970703,"speaker":"A"},{"text":"then","start":2118260,"end":2118540,"confidence":0.9995117,"speaker":"A"},{"text":"we've","start":2118620,"end":2119180,"confidence":0.9142253,"speaker":"A"},{"text":"then","start":2119180,"end":2119500,"confidence":0.953125,"speaker":"A"},{"text":"in","start":2119500,"end":2119700,"confidence":0.984375,"speaker":"A"},{"text":"our.","start":2119700,"end":2119980,"confidence":0.9980469,"speaker":"A"},{"text":"We've","start":2120140,"end":2120500,"confidence":0.9944661,"speaker":"A"},{"text":"abstracted","start":2120500,"end":2121220,"confidence":0.9979248,"speaker":"A"},{"text":"a","start":2121220,"end":2121340,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":2121340,"end":2121460,"confidence":1,"speaker":"A"},{"text":"of","start":2121460,"end":2121580,"confidence":1,"speaker":"A"},{"text":"this","start":2121580,"end":2121740,"confidence":0.99658203,"speaker":"A"},{"text":"in","start":2121740,"end":2121940,"confidence":0.72802734,"speaker":"A"},{"text":"miskit.","start":2121940,"end":2122620,"confidence":0.83813477,"speaker":"A"},{"text":"So","start":2122940,"end":2123180,"confidence":1,"speaker":"A"},{"text":"that","start":2123180,"end":2123340,"confidence":1,"speaker":"A"},{"text":"way","start":2123340,"end":2123660,"confidence":0.99902344,"speaker":"A"},{"text":"we","start":2123980,"end":2124260,"confidence":1,"speaker":"A"},{"text":"also","start":2124260,"end":2124460,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2124460,"end":2124740,"confidence":1,"speaker":"A"},{"text":"now","start":2124740,"end":2125100,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":2125580,"end":2125860,"confidence":0.99658203,"speaker":"A"},{"text":"cloud","start":2125860,"end":2126220,"confidence":0.9638672,"speaker":"A"},{"text":"cloud","start":2126540,"end":2127100,"confidence":0.9489746,"speaker":"A"},{"text":"error","start":2127100,"end":2127500,"confidence":0.94311523,"speaker":"A"},{"text":"type","start":2127500,"end":2127980,"confidence":0.99975586,"speaker":"A"},{"text":"which","start":2128540,"end":2128900,"confidence":1,"speaker":"A"},{"text":"gives","start":2128900,"end":2129220,"confidence":1,"speaker":"A"},{"text":"us","start":2129220,"end":2129380,"confidence":1,"speaker":"A"},{"text":"a","start":2129380,"end":2129500,"confidence":1,"speaker":"A"},{"text":"lot","start":2129500,"end":2129660,"confidence":1,"speaker":"A"},{"text":"more","start":2129660,"end":2129980,"confidence":0.9995117,"speaker":"A"},{"text":"info","start":2130060,"end":2130700,"confidence":0.99975586,"speaker":"A"},{"text":"regarding","start":2130860,"end":2131460,"confidence":0.87874347,"speaker":"A"},{"text":"that.","start":2131460,"end":2131820,"confidence":0.99853516,"speaker":"A"},{"text":"So","start":2133900,"end":2134220,"confidence":0.9975586,"speaker":"A"},{"text":"that's","start":2134220,"end":2134540,"confidence":0.9998372,"speaker":"A"},{"text":"how","start":2134540,"end":2134660,"confidence":1,"speaker":"A"},{"text":"we","start":2134660,"end":2134820,"confidence":1,"speaker":"A"},{"text":"handle","start":2134820,"end":2135180,"confidence":0.99975586,"speaker":"A"},{"text":"errors.","start":2135180,"end":2135740,"confidence":0.99912107,"speaker":"A"},{"text":"And","start":2135820,"end":2136140,"confidence":0.99658203,"speaker":"A"},{"text":"everything","start":2136140,"end":2136460,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2137240,"end":2137360,"confidence":0.9736328,"speaker":"A"},{"text":"do","start":2137360,"end":2137520,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":2137520,"end":2137680,"confidence":0.90283203,"speaker":"A"},{"text":"the","start":2137680,"end":2137800,"confidence":0.92822266,"speaker":"A"},{"text":"abs,","start":2137800,"end":2138080,"confidence":0.4827881,"speaker":"A"},{"text":"the","start":2138080,"end":2138360,"confidence":0.9897461,"speaker":"A"},{"text":"more","start":2138360,"end":2138600,"confidence":0.99072266,"speaker":"A"},{"text":"abstract","start":2138600,"end":2138960,"confidence":0.8538411,"speaker":"A"},{"text":"higher","start":2138960,"end":2139280,"confidence":0.99365234,"speaker":"A"},{"text":"up","start":2139280,"end":2139560,"confidence":0.9970703,"speaker":"A"},{"text":"stuff","start":2139560,"end":2139960,"confidence":0.9713542,"speaker":"A"},{"text":"is","start":2140280,"end":2140680,"confidence":0.99902344,"speaker":"A"},{"text":"done","start":2140680,"end":2141080,"confidence":0.9995117,"speaker":"A"},{"text":"using","start":2141800,"end":2142200,"confidence":1,"speaker":"A"},{"text":"type","start":2142360,"end":2142840,"confidence":0.77783203,"speaker":"A"},{"text":"throws","start":2142840,"end":2143320,"confidence":0.9947917,"speaker":"A"},{"text":"like","start":2143320,"end":2143560,"confidence":0.9794922,"speaker":"A"},{"text":"I","start":2143560,"end":2143760,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2143760,"end":2143960,"confidence":0.9995117,"speaker":"A"},{"text":"type","start":2143960,"end":2144240,"confidence":0.7751465,"speaker":"A"},{"text":"throws","start":2144240,"end":2144560,"confidence":0.9274089,"speaker":"A"},{"text":"and","start":2144560,"end":2144680,"confidence":0.5439453,"speaker":"A"},{"text":"everything.","start":2144680,"end":2144920,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2145160,"end":2145560,"confidence":0.9941406,"speaker":"A"},{"text":"that's","start":2145960,"end":2146360,"confidence":0.9996745,"speaker":"A"},{"text":"how","start":2146360,"end":2146440,"confidence":1,"speaker":"A"},{"text":"I","start":2146440,"end":2146560,"confidence":0.9995117,"speaker":"A"},{"text":"handle","start":2146560,"end":2146960,"confidence":0.9951172,"speaker":"A"},{"text":"that.","start":2146960,"end":2147240,"confidence":0.9970703,"speaker":"A"},{"text":"Let","start":2148600,"end":2148880,"confidence":0.97753906,"speaker":"A"},{"text":"me","start":2148880,"end":2149040,"confidence":0.9995117,"speaker":"A"},{"text":"check","start":2149040,"end":2149400,"confidence":0.99780273,"speaker":"A"},{"text":"one","start":2150600,"end":2150920,"confidence":0.99560547,"speaker":"A"},{"text":"last","start":2150920,"end":2151160,"confidence":0.99853516,"speaker":"A"},{"text":"piece","start":2151160,"end":2151440,"confidence":1,"speaker":"A"},{"text":"I","start":2151440,"end":2151560,"confidence":0.99853516,"speaker":"A"},{"text":"wanted","start":2151560,"end":2151800,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2151800,"end":2151920,"confidence":0.99902344,"speaker":"A"},{"text":"cover.","start":2151920,"end":2152200,"confidence":0.9980469,"speaker":"A"},{"text":"The","start":2154920,"end":2155200,"confidence":0.3737793,"speaker":"A"},{"text":"last","start":2155200,"end":2155360,"confidence":0.9980469,"speaker":"A"},{"text":"piece","start":2155360,"end":2155600,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2155600,"end":2155720,"confidence":0.97998047,"speaker":"A"},{"text":"want","start":2155720,"end":2155840,"confidence":0.9321289,"speaker":"A"},{"text":"to","start":2155840,"end":2155960,"confidence":0.9916992,"speaker":"A"},{"text":"cover","start":2155960,"end":2156160,"confidence":1,"speaker":"A"},{"text":"is","start":2156160,"end":2156520,"confidence":0.99902344,"speaker":"A"},{"text":"really","start":2156760,"end":2157120,"confidence":0.9995117,"speaker":"A"},{"text":"cool.","start":2157120,"end":2157440,"confidence":0.99975586,"speaker":"A"},{"text":"And","start":2157440,"end":2157680,"confidence":0.7548828,"speaker":"A"},{"text":"that","start":2157680,"end":2157920,"confidence":1,"speaker":"A"},{"text":"is","start":2157920,"end":2158200,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2158200,"end":2158520,"confidence":1,"speaker":"A"},{"text":"authentication","start":2158520,"end":2159280,"confidence":0.9998779,"speaker":"A"},{"text":"layer.","start":2159280,"end":2159800,"confidence":0.9975586,"speaker":"A"},{"text":"So","start":2160200,"end":2160480,"confidence":0.9770508,"speaker":"A"},{"text":"Open","start":2160480,"end":2160720,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":2160720,"end":2161320,"confidence":0.9436035,"speaker":"A"},{"text":"provides","start":2161320,"end":2161920,"confidence":0.99975586,"speaker":"A"},{"text":"what's","start":2161920,"end":2162240,"confidence":0.99902344,"speaker":"A"},{"text":"called","start":2162240,"end":2162480,"confidence":1,"speaker":"A"},{"text":"middleware","start":2162480,"end":2163160,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2164440,"end":2164680,"confidence":0.9550781,"speaker":"A"},{"text":"that","start":2164760,"end":2165080,"confidence":0.99902344,"speaker":"A"},{"text":"allows","start":2165080,"end":2165440,"confidence":1,"speaker":"A"},{"text":"you","start":2165440,"end":2165640,"confidence":0.9995117,"speaker":"A"},{"text":"to,","start":2165640,"end":2165960,"confidence":0.99072266,"speaker":"A"},{"text":"when","start":2166200,"end":2166480,"confidence":0.99658203,"speaker":"A"},{"text":"you","start":2166480,"end":2166600,"confidence":0.9892578,"speaker":"A"},{"text":"create","start":2166600,"end":2166720,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2166720,"end":2166880,"confidence":0.99902344,"speaker":"A"},{"text":"client","start":2166880,"end":2167120,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":2167120,"end":2167320,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2167320,"end":2167520,"confidence":0.9916992,"speaker":"A"},{"text":"server,","start":2167520,"end":2167840,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":2167840,"end":2167960,"confidence":0.99902344,"speaker":"A"},{"text":"can","start":2167960,"end":2168080,"confidence":1,"speaker":"A"},{"text":"plug","start":2168080,"end":2168360,"confidence":0.99975586,"speaker":"A"},{"text":"that","start":2168360,"end":2168560,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":2168560,"end":2168760,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":2168760,"end":2168960,"confidence":0.9980469,"speaker":"A"},{"text":"it","start":2168960,"end":2169120,"confidence":0.99902344,"speaker":"A"},{"text":"will","start":2169120,"end":2169280,"confidence":0.99902344,"speaker":"A"},{"text":"handle","start":2169280,"end":2169800,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":2169880,"end":2170240,"confidence":0.9291992,"speaker":"A"},{"text":"let's","start":2170240,"end":2170520,"confidence":0.99934894,"speaker":"A"},{"text":"say","start":2170520,"end":2170640,"confidence":1,"speaker":"A"},{"text":"you","start":2170640,"end":2170760,"confidence":1,"speaker":"A"},{"text":"need","start":2170760,"end":2170880,"confidence":1,"speaker":"A"},{"text":"to","start":2170880,"end":2171000,"confidence":1,"speaker":"A"},{"text":"make","start":2171000,"end":2171120,"confidence":1,"speaker":"A"},{"text":"modifications","start":2171120,"end":2171840,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":2171840,"end":2172080,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2172080,"end":2172240,"confidence":0.9951172,"speaker":"A"},{"text":"request","start":2172240,"end":2172600,"confidence":0.9995117,"speaker":"A"},{"text":"or","start":2172600,"end":2172800,"confidence":0.98779297,"speaker":"A"},{"text":"response.","start":2172800,"end":2173400,"confidence":0.9970703,"speaker":"A"},{"text":"When","start":2173640,"end":2173920,"confidence":1,"speaker":"A"},{"text":"it","start":2173920,"end":2174080,"confidence":0.99902344,"speaker":"A"},{"text":"comes","start":2174080,"end":2174280,"confidence":1,"speaker":"A"},{"text":"in,","start":2174280,"end":2174600,"confidence":0.99658203,"speaker":"A"},{"text":"you","start":2174680,"end":2174960,"confidence":1,"speaker":"A"},{"text":"can","start":2174960,"end":2175120,"confidence":0.9995117,"speaker":"A"},{"text":"intercept","start":2175120,"end":2175520,"confidence":0.8586426,"speaker":"A"},{"text":"it","start":2175520,"end":2175760,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2175760,"end":2175880,"confidence":0.9995117,"speaker":"A"},{"text":"make","start":2175880,"end":2176040,"confidence":0.9995117,"speaker":"A"},{"text":"whatever","start":2176040,"end":2176360,"confidence":0.9995117,"speaker":"A"},{"text":"modifications","start":2176360,"end":2177040,"confidence":0.99886066,"speaker":"A"},{"text":"you","start":2177040,"end":2177280,"confidence":0.9995117,"speaker":"A"},{"text":"want","start":2177280,"end":2177440,"confidence":0.9277344,"speaker":"A"},{"text":"to","start":2177440,"end":2177560,"confidence":0.9980469,"speaker":"A"},{"text":"make.","start":2177560,"end":2177800,"confidence":0.9980469,"speaker":"A"},{"text":"And","start":2179239,"end":2179519,"confidence":0.9013672,"speaker":"A"},{"text":"in","start":2179519,"end":2179640,"confidence":1,"speaker":"A"},{"text":"this","start":2179640,"end":2179800,"confidence":1,"speaker":"A"},{"text":"case","start":2179800,"end":2180120,"confidence":1,"speaker":"A"},{"text":"what","start":2180840,"end":2181160,"confidence":0.9995117,"speaker":"A"},{"text":"we've","start":2181160,"end":2181440,"confidence":0.9941406,"speaker":"A"},{"text":"done","start":2181440,"end":2181720,"confidence":1,"speaker":"A"},{"text":"is","start":2181720,"end":2182120,"confidence":0.9970703,"speaker":"A"},{"text":"I've","start":2182520,"end":2182880,"confidence":0.9954427,"speaker":"A"},{"text":"created","start":2182880,"end":2183320,"confidence":0.99975586,"speaker":"A"},{"text":"an","start":2184520,"end":2184840,"confidence":0.9926758,"speaker":"A"},{"text":"authentication","start":2184840,"end":2185480,"confidence":1,"speaker":"A"},{"text":"middleware","start":2185480,"end":2186200,"confidence":0.9993164,"speaker":"A"},{"text":"which","start":2187480,"end":2187840,"confidence":0.99902344,"speaker":"A"},{"text":"then","start":2187840,"end":2188200,"confidence":0.99902344,"speaker":"A"},{"text":"sees","start":2188600,"end":2189080,"confidence":0.8354492,"speaker":"A"},{"text":"if","start":2189080,"end":2189280,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":2189280,"end":2189480,"confidence":0.99365234,"speaker":"A"},{"text":"have","start":2189480,"end":2189800,"confidence":0.9946289,"speaker":"A"},{"text":"what's","start":2191430,"end":2191670,"confidence":0.9420573,"speaker":"A"},{"text":"called","start":2191670,"end":2191790,"confidence":1,"speaker":"A"},{"text":"a","start":2191790,"end":2191910,"confidence":0.9916992,"speaker":"A"},{"text":"token","start":2191910,"end":2192270,"confidence":0.9996745,"speaker":"A"},{"text":"manager","start":2192270,"end":2192870,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2193990,"end":2194390,"confidence":0.98828125,"speaker":"A"},{"text":"an","start":2194390,"end":2194750,"confidence":0.7910156,"speaker":"A"},{"text":"authentic","start":2194750,"end":2195310,"confidence":0.97542316,"speaker":"A"},{"text":"you","start":2195310,"end":2195470,"confidence":0.9970703,"speaker":"A"},{"text":"have","start":2195470,"end":2195630,"confidence":1,"speaker":"A"},{"text":"that","start":2195630,"end":2195870,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2195870,"end":2196190,"confidence":0.9975586,"speaker":"A"},{"text":"an","start":2196190,"end":2196430,"confidence":0.9980469,"speaker":"A"},{"text":"authentication","start":2196430,"end":2197070,"confidence":0.99938965,"speaker":"A"},{"text":"method.","start":2197070,"end":2197590,"confidence":0.9983724,"speaker":"A"},{"text":"And","start":2198070,"end":2198430,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":2198430,"end":2198670,"confidence":1,"speaker":"A"},{"text":"way","start":2198670,"end":2198790,"confidence":1,"speaker":"A"},{"text":"it","start":2198790,"end":2198910,"confidence":0.99902344,"speaker":"A"},{"text":"works","start":2198910,"end":2199350,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2199510,"end":2199910,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":2199910,"end":2200230,"confidence":1,"speaker":"A"},{"text":"pick","start":2200230,"end":2200550,"confidence":0.99853516,"speaker":"A"},{"text":"what","start":2201190,"end":2201550,"confidence":0.99365234,"speaker":"A"},{"text":"type","start":2201550,"end":2201830,"confidence":0.99975586,"speaker":"A"},{"text":"of","start":2201830,"end":2201990,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":2201990,"end":2202550,"confidence":0.9998779,"speaker":"A"},{"text":"you","start":2202550,"end":2202710,"confidence":0.99902344,"speaker":"A"},{"text":"want","start":2202710,"end":2202830,"confidence":0.9165039,"speaker":"A"},{"text":"to","start":2202830,"end":2202950,"confidence":0.99609375,"speaker":"A"},{"text":"use.","start":2202950,"end":2203070,"confidence":1,"speaker":"A"},{"text":"If","start":2203070,"end":2203230,"confidence":1,"speaker":"A"},{"text":"you","start":2203230,"end":2203350,"confidence":1,"speaker":"A"},{"text":"already","start":2203350,"end":2203510,"confidence":0.99853516,"speaker":"A"},{"text":"have","start":2203510,"end":2203670,"confidence":1,"speaker":"A"},{"text":"like","start":2203670,"end":2203790,"confidence":0.99560547,"speaker":"A"},{"text":"a","start":2203790,"end":2203910,"confidence":0.9995117,"speaker":"A"},{"text":"pre","start":2203910,"end":2204030,"confidence":1,"speaker":"A"},{"text":"existing","start":2204030,"end":2204430,"confidence":0.98551434,"speaker":"A"},{"text":"web","start":2204430,"end":2204670,"confidence":0.99975586,"speaker":"A"},{"text":"token","start":2204670,"end":2205190,"confidence":0.9552409,"speaker":"A"},{"text":"or","start":2205590,"end":2205950,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":2205950,"end":2206190,"confidence":0.99853516,"speaker":"A"},{"text":"already","start":2206190,"end":2206470,"confidence":0.99853516,"speaker":"A"},{"text":"have,","start":2206470,"end":2206789,"confidence":0.92626953,"speaker":"A"},{"text":"or","start":2206789,"end":2207070,"confidence":0.95996094,"speaker":"A"},{"text":"you,","start":2207070,"end":2207350,"confidence":0.9916992,"speaker":"A"},{"text":"you","start":2207350,"end":2207550,"confidence":0.9770508,"speaker":"A"},{"text":"know,","start":2207550,"end":2207710,"confidence":0.9716797,"speaker":"A"},{"text":"have","start":2207710,"end":2207910,"confidence":0.6328125,"speaker":"A"},{"text":"your","start":2207910,"end":2208110,"confidence":0.99853516,"speaker":"A"},{"text":"key","start":2208110,"end":2208310,"confidence":0.99609375,"speaker":"A"},{"text":"ID","start":2208310,"end":2208590,"confidence":0.97753906,"speaker":"A"},{"text":"and","start":2208590,"end":2208830,"confidence":0.99902344,"speaker":"A"},{"text":"your","start":2208830,"end":2208990,"confidence":0.99902344,"speaker":"A"},{"text":"private","start":2208990,"end":2209230,"confidence":1,"speaker":"A"},{"text":"key","start":2209230,"end":2209510,"confidence":0.9995117,"speaker":"A"},{"text":"already,","start":2209510,"end":2209830,"confidence":0.99560547,"speaker":"A"},{"text":"or","start":2209910,"end":2210190,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2210190,"end":2210350,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":2210350,"end":2210510,"confidence":1,"speaker":"A"},{"text":"have","start":2210510,"end":2210670,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2210670,"end":2210790,"confidence":0.98339844,"speaker":"A"},{"text":"API","start":2210790,"end":2211190,"confidence":0.9992676,"speaker":"A"},{"text":"token.","start":2211190,"end":2211750,"confidence":0.99934894,"speaker":"A"},{"text":"We've","start":2212390,"end":2212790,"confidence":0.9996745,"speaker":"A"},{"text":"created","start":2212790,"end":2213190,"confidence":0.9995117,"speaker":"A"},{"text":"basically","start":2213190,"end":2213590,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2213590,"end":2213750,"confidence":0.99609375,"speaker":"A"},{"text":"middleware","start":2213750,"end":2214270,"confidence":0.99716794,"speaker":"A"},{"text":"that","start":2214270,"end":2214470,"confidence":0.99902344,"speaker":"A"},{"text":"uses","start":2214470,"end":2214870,"confidence":0.9992676,"speaker":"A"},{"text":"that.","start":2214870,"end":2215190,"confidence":0.98339844,"speaker":"A"},{"text":"So","start":2216560,"end":2216800,"confidence":0.7055664,"speaker":"A"},{"text":"this","start":2218880,"end":2219120,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2219120,"end":2219280,"confidence":0.99902344,"speaker":"A"},{"text":"how","start":2219280,"end":2219560,"confidence":1,"speaker":"A"},{"text":"it","start":2219560,"end":2219840,"confidence":0.9995117,"speaker":"A"},{"text":"creates","start":2219840,"end":2220200,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2220200,"end":2220360,"confidence":0.9995117,"speaker":"A"},{"text":"headers","start":2220360,"end":2220800,"confidence":0.99902344,"speaker":"A"},{"text":"for","start":2221040,"end":2221360,"confidence":0.98583984,"speaker":"A"},{"text":"server","start":2221360,"end":2221720,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":2221720,"end":2221920,"confidence":0.96972656,"speaker":"A"},{"text":"server.","start":2221920,"end":2222400,"confidence":0.9992676,"speaker":"A"},{"text":"So","start":2222800,"end":2223040,"confidence":0.8354492,"speaker":"A"},{"text":"it","start":2223040,"end":2223160,"confidence":0.98583984,"speaker":"A"},{"text":"does","start":2223160,"end":2223320,"confidence":1,"speaker":"A"},{"text":"all","start":2223320,"end":2223480,"confidence":1,"speaker":"A"},{"text":"this","start":2223480,"end":2223640,"confidence":0.9970703,"speaker":"A"},{"text":"for","start":2223640,"end":2223840,"confidence":0.9995117,"speaker":"A"},{"text":"us.","start":2223840,"end":2224160,"confidence":0.99072266,"speaker":"A"},{"text":"And","start":2225760,"end":2226040,"confidence":0.6791992,"speaker":"A"},{"text":"then","start":2226040,"end":2226320,"confidence":0.9941406,"speaker":"A"},{"text":"what","start":2227520,"end":2227760,"confidence":0.9873047,"speaker":"A"},{"text":"I","start":2227760,"end":2227880,"confidence":0.9980469,"speaker":"A"},{"text":"added,","start":2227880,"end":2228160,"confidence":0.99658203,"speaker":"A"},{"text":"which","start":2228480,"end":2228760,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":2228760,"end":2228920,"confidence":0.9995117,"speaker":"A"},{"text":"think","start":2228920,"end":2229040,"confidence":1,"speaker":"A"},{"text":"is","start":2229040,"end":2229160,"confidence":0.9975586,"speaker":"A"},{"text":"really","start":2229160,"end":2229320,"confidence":0.9995117,"speaker":"A"},{"text":"nice,","start":2229320,"end":2229600,"confidence":1,"speaker":"A"},{"text":"is","start":2229600,"end":2229800,"confidence":0.68310547,"speaker":"A"},{"text":"called","start":2229800,"end":2229960,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2229960,"end":2230120,"confidence":0.9975586,"speaker":"A"},{"text":"adaptive","start":2230120,"end":2230720,"confidence":0.9437256,"speaker":"A"},{"text":"token","start":2230720,"end":2231240,"confidence":0.84195966,"speaker":"A"},{"text":"manager.","start":2231240,"end":2231760,"confidence":0.9963379,"speaker":"A"},{"text":"And","start":2232240,"end":2232520,"confidence":0.6923828,"speaker":"A"},{"text":"the","start":2232520,"end":2232680,"confidence":0.9995117,"speaker":"A"},{"text":"idea","start":2232680,"end":2233000,"confidence":1,"speaker":"A"},{"text":"with","start":2233000,"end":2233160,"confidence":0.99609375,"speaker":"A"},{"text":"that","start":2233160,"end":2233360,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2233360,"end":2233600,"confidence":0.9975586,"speaker":"A"},{"text":"like","start":2233600,"end":2233880,"confidence":0.8354492,"speaker":"A"},{"text":"let's","start":2233880,"end":2234240,"confidence":0.9013672,"speaker":"A"},{"text":"say","start":2234240,"end":2234560,"confidence":0.9995117,"speaker":"A"},{"text":"you're","start":2236960,"end":2237360,"confidence":0.9977214,"speaker":"A"},{"text":"using","start":2237360,"end":2237520,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2237520,"end":2237720,"confidence":0.99902344,"speaker":"A"},{"text":"client","start":2237720,"end":2238160,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2238240,"end":2238560,"confidence":0.9926758,"speaker":"A"},{"text":"you","start":2238560,"end":2238880,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2238880,"end":2239280,"confidence":1,"speaker":"A"},{"text":"the","start":2239280,"end":2239560,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":2239560,"end":2239800,"confidence":0.9995117,"speaker":"A"},{"text":"authentication","start":2239800,"end":2240480,"confidence":0.8408203,"speaker":"A"},{"text":"token","start":2240480,"end":2240920,"confidence":0.9995117,"speaker":"A"},{"text":"now","start":2240920,"end":2241200,"confidence":0.91308594,"speaker":"A"},{"text":"and","start":2241440,"end":2241720,"confidence":0.94628906,"speaker":"A"},{"text":"then","start":2241720,"end":2242000,"confidence":0.97216797,"speaker":"A"},{"text":"this","start":2242080,"end":2242360,"confidence":0.9975586,"speaker":"A"},{"text":"allows","start":2242360,"end":2242640,"confidence":1,"speaker":"A"},{"text":"you","start":2242640,"end":2242760,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2242760,"end":2242920,"confidence":0.9980469,"speaker":"A"},{"text":"upgrade","start":2242920,"end":2243440,"confidence":0.9767253,"speaker":"A"},{"text":"with","start":2243810,"end":2243970,"confidence":0.9770508,"speaker":"A"},{"text":"that","start":2243970,"end":2244170,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":2244170,"end":2244410,"confidence":0.998291,"speaker":"A"},{"text":"authentication","start":2244410,"end":2245090,"confidence":0.99938965,"speaker":"A"},{"text":"token","start":2245090,"end":2245450,"confidence":0.9991862,"speaker":"A"},{"text":"to","start":2245450,"end":2245610,"confidence":0.99560547,"speaker":"A"},{"text":"the","start":2245610,"end":2245770,"confidence":1,"speaker":"A"},{"text":"private","start":2245770,"end":2245970,"confidence":1,"speaker":"A"},{"text":"database","start":2245970,"end":2246490,"confidence":0.9998372,"speaker":"A"},{"text":"and","start":2246490,"end":2246690,"confidence":0.99853516,"speaker":"A"},{"text":"have","start":2246690,"end":2246930,"confidence":0.99560547,"speaker":"A"},{"text":"access","start":2246930,"end":2247210,"confidence":1,"speaker":"A"},{"text":"to","start":2247210,"end":2247450,"confidence":0.9995117,"speaker":"A"},{"text":"that.","start":2247450,"end":2247730,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2250530,"end":2250850,"confidence":0.97558594,"speaker":"A"},{"text":"and","start":2250850,"end":2251050,"confidence":0.97558594,"speaker":"A"},{"text":"then","start":2251050,"end":2251210,"confidence":0.97753906,"speaker":"A"},{"text":"all","start":2251210,"end":2251490,"confidence":0.9658203,"speaker":"A"},{"text":"the,","start":2251490,"end":2251890,"confidence":0.9921875,"speaker":"A"},{"text":"all","start":2252690,"end":2252970,"confidence":0.9013672,"speaker":"A"},{"text":"the","start":2252970,"end":2253170,"confidence":0.99609375,"speaker":"A"},{"text":"signing","start":2253170,"end":2253610,"confidence":0.99658203,"speaker":"A"},{"text":"is","start":2253610,"end":2253770,"confidence":0.9926758,"speaker":"A"},{"text":"done","start":2253770,"end":2253970,"confidence":1,"speaker":"A"},{"text":"before","start":2253970,"end":2254290,"confidence":0.86816406,"speaker":"A"},{"text":"you","start":2254290,"end":2254610,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":2254610,"end":2254810,"confidence":0.9550781,"speaker":"A"},{"text":"miskit","start":2254810,"end":2255490,"confidence":0.8145752,"speaker":"A"},{"text":"for","start":2255650,"end":2256010,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2256010,"end":2256250,"confidence":0.99902344,"speaker":"A"},{"text":"server","start":2256250,"end":2256530,"confidence":0.99902344,"speaker":"A"},{"text":"to","start":2256530,"end":2256690,"confidence":0.8510742,"speaker":"A"},{"text":"server","start":2256690,"end":2257050,"confidence":0.9995117,"speaker":"A"},{"text":"because","start":2257050,"end":2257250,"confidence":0.9995117,"speaker":"A"},{"text":"stuff","start":2257250,"end":2257490,"confidence":0.9991862,"speaker":"A"},{"text":"that","start":2257490,"end":2257650,"confidence":0.68603516,"speaker":"A"},{"text":"needs","start":2257650,"end":2257850,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2257850,"end":2257970,"confidence":1,"speaker":"A"},{"text":"be","start":2257970,"end":2258090,"confidence":1,"speaker":"A"},{"text":"signed,","start":2258090,"end":2258330,"confidence":0.79589844,"speaker":"A"},{"text":"etc.","start":2258330,"end":2259010,"confidence":0.88311,"speaker":"A"},{"text":"And","start":2259570,"end":2259849,"confidence":0.99609375,"speaker":"A"},{"text":"it","start":2259849,"end":2260010,"confidence":0.99902344,"speaker":"A"},{"text":"takes","start":2260010,"end":2260250,"confidence":1,"speaker":"A"},{"text":"care","start":2260250,"end":2260410,"confidence":1,"speaker":"A"},{"text":"of","start":2260410,"end":2260610,"confidence":1,"speaker":"A"},{"text":"all","start":2260610,"end":2260850,"confidence":0.9951172,"speaker":"A"},{"text":"that.","start":2260850,"end":2261170,"confidence":0.99560547,"speaker":"A"},{"text":"All","start":2261570,"end":2261890,"confidence":0.9902344,"speaker":"A"},{"text":"stuff","start":2261890,"end":2262170,"confidence":0.9947917,"speaker":"A"},{"text":"that","start":2262170,"end":2262450,"confidence":0.99853516,"speaker":"A"},{"text":"Claude","start":2262690,"end":2263330,"confidence":0.7474365,"speaker":"A"},{"text":"was","start":2263330,"end":2263650,"confidence":0.9995117,"speaker":"A"},{"text":"essentially","start":2263650,"end":2264210,"confidence":0.9995117,"speaker":"A"},{"text":"able","start":2264210,"end":2264450,"confidence":0.9980469,"speaker":"A"},{"text":"to","start":2264450,"end":2264770,"confidence":1,"speaker":"A"},{"text":"decipher","start":2264850,"end":2265610,"confidence":0.99593097,"speaker":"A"},{"text":"from","start":2265610,"end":2265970,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2266610,"end":2267010,"confidence":0.99072266,"speaker":"A"},{"text":"documentation.","start":2269340,"end":2270060,"confidence":0.9116211,"speaker":"A"},{"text":"There's","start":2272620,"end":2273020,"confidence":0.9972331,"speaker":"A"},{"text":"one","start":2273020,"end":2273140,"confidence":1,"speaker":"A"},{"text":"more","start":2273140,"end":2273300,"confidence":1,"speaker":"A"},{"text":"thing","start":2273300,"end":2273460,"confidence":1,"speaker":"A"},{"text":"I","start":2273460,"end":2273620,"confidence":0.9995117,"speaker":"A"},{"text":"wanted","start":2273620,"end":2273860,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":2273860,"end":2274020,"confidence":1,"speaker":"A"},{"text":"show.","start":2274020,"end":2274300,"confidence":0.99902344,"speaker":"A"},{"text":"If","start":2276380,"end":2276660,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2276660,"end":2276780,"confidence":1,"speaker":"A"},{"text":"want","start":2276780,"end":2276860,"confidence":0.9921875,"speaker":"A"},{"text":"to","start":2276860,"end":2276980,"confidence":0.9995117,"speaker":"A"},{"text":"hop","start":2276980,"end":2277140,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":2277140,"end":2277300,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":2277300,"end":2277460,"confidence":1,"speaker":"A"},{"text":"a","start":2277460,"end":2277620,"confidence":0.9941406,"speaker":"A"},{"text":"question","start":2277620,"end":2277900,"confidence":1,"speaker":"A"},{"text":"while","start":2278380,"end":2278740,"confidence":0.9946289,"speaker":"A"},{"text":"I","start":2278740,"end":2279100,"confidence":0.99902344,"speaker":"A"},{"text":"pull","start":2279260,"end":2279620,"confidence":0.9995117,"speaker":"A"},{"text":"something","start":2279620,"end":2279860,"confidence":1,"speaker":"A"},{"text":"up,","start":2279860,"end":2280220,"confidence":0.99902344,"speaker":"A"},{"text":"feel","start":2280300,"end":2280620,"confidence":0.9995117,"speaker":"A"},{"text":"free.","start":2280620,"end":2280940,"confidence":1,"speaker":"A"},{"text":"No","start":2301190,"end":2301350,"confidence":0.9892578,"speaker":"A"},{"text":"questions.","start":2301350,"end":2301910,"confidence":0.9995117,"speaker":"A"},{"text":"Cool.","start":2303910,"end":2304390,"confidence":0.8347168,"speaker":"A"},{"text":"So","start":2304790,"end":2305030,"confidence":0.9921875,"speaker":"A"},{"text":"I'm","start":2305030,"end":2305190,"confidence":0.94905597,"speaker":"A"},{"text":"going","start":2305190,"end":2305270,"confidence":0.77441406,"speaker":"A"},{"text":"to","start":2305270,"end":2305350,"confidence":0.9980469,"speaker":"A"},{"text":"show","start":2305350,"end":2305510,"confidence":0.9975586,"speaker":"A"},{"text":"one","start":2305510,"end":2305710,"confidence":0.9995117,"speaker":"A"},{"text":"last","start":2305710,"end":2305950,"confidence":0.9995117,"speaker":"A"},{"text":"thing","start":2305950,"end":2306310,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2306950,"end":2307230,"confidence":0.9921875,"speaker":"A"},{"text":"that","start":2307230,"end":2307430,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2307430,"end":2307750,"confidence":0.99609375,"speaker":"A"},{"text":"how","start":2308230,"end":2308630,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":2308710,"end":2308990,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":2308990,"end":2309190,"confidence":1,"speaker":"A"},{"text":"actually","start":2309190,"end":2309470,"confidence":0.9970703,"speaker":"A"},{"text":"deploy","start":2309470,"end":2309990,"confidence":1,"speaker":"A"},{"text":"this?","start":2309990,"end":2310310,"confidence":0.9995117,"speaker":"A"},{"text":"Is","start":2313350,"end":2313630,"confidence":0.9980469,"speaker":"A"},{"text":"this","start":2313630,"end":2313830,"confidence":0.9995117,"speaker":"A"},{"text":"too","start":2313830,"end":2314070,"confidence":0.9975586,"speaker":"A"},{"text":"big,","start":2314070,"end":2314350,"confidence":1,"speaker":"A"},{"text":"too","start":2314350,"end":2314590,"confidence":0.98779297,"speaker":"A"},{"text":"small?","start":2314590,"end":2314870,"confidence":0.99853516,"speaker":"A"},{"text":"Looks","start":2316150,"end":2316510,"confidence":0.8227539,"speaker":"A"},{"text":"okay.","start":2316510,"end":2316950,"confidence":0.9710286,"speaker":"A"},{"text":"That","start":2317590,"end":2317870,"confidence":0.97265625,"speaker":"C"},{"text":"looks","start":2317870,"end":2318150,"confidence":0.99902344,"speaker":"C"},{"text":"good.","start":2318150,"end":2318390,"confidence":0.9921875,"speaker":"C"},{"text":"Yeah,","start":2318710,"end":2319030,"confidence":0.992513,"speaker":"B"},{"text":"it","start":2319030,"end":2319110,"confidence":0.79003906,"speaker":"B"},{"text":"looks","start":2319110,"end":2319270,"confidence":0.99902344,"speaker":"B"},{"text":"good.","start":2319270,"end":2319430,"confidence":0.9951172,"speaker":"B"},{"text":"Okay,","start":2319430,"end":2319750,"confidence":0.9550781,"speaker":"A"},{"text":"cool.","start":2319750,"end":2320070,"confidence":0.99121094,"speaker":"A"},{"text":"So","start":2323850,"end":2324050,"confidence":0.9604492,"speaker":"A"},{"text":"essentially","start":2324050,"end":2324530,"confidence":0.9962158,"speaker":"A"},{"text":"what","start":2324530,"end":2324690,"confidence":0.9995117,"speaker":"A"},{"text":"I've","start":2324690,"end":2324930,"confidence":0.99886066,"speaker":"A"},{"text":"done","start":2324930,"end":2325210,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2325530,"end":2325930,"confidence":0.99365234,"speaker":"A"},{"text":"I'm","start":2326570,"end":2326930,"confidence":0.95214844,"speaker":"A"},{"text":"using","start":2326930,"end":2327210,"confidence":1,"speaker":"A"},{"text":"GitHub","start":2327370,"end":2327890,"confidence":0.9975586,"speaker":"A"},{"text":"Actions.","start":2327890,"end":2328490,"confidence":0.9992676,"speaker":"A"},{"text":"There's","start":2329290,"end":2329690,"confidence":0.9991862,"speaker":"A"},{"text":"a","start":2329690,"end":2329770,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":2329770,"end":2329930,"confidence":1,"speaker":"A"},{"text":"you","start":2329930,"end":2330130,"confidence":0.99902344,"speaker":"A"},{"text":"can.","start":2330130,"end":2330410,"confidence":0.99902344,"speaker":"A"},{"text":"This","start":2333130,"end":2333410,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2333410,"end":2333530,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":2333530,"end":2333770,"confidence":0.98876953,"speaker":"A"},{"text":"public","start":2334010,"end":2334370,"confidence":1,"speaker":"A"},{"text":"by","start":2334370,"end":2334570,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2334570,"end":2334690,"confidence":0.9995117,"speaker":"A"},{"text":"way,","start":2334690,"end":2334970,"confidence":1,"speaker":"A"},{"text":"so","start":2335050,"end":2335450,"confidence":0.9321289,"speaker":"A"},{"text":"I","start":2335850,"end":2336130,"confidence":0.99902344,"speaker":"A"},{"text":"will","start":2336130,"end":2336370,"confidence":0.86621094,"speaker":"A"},{"text":"provide","start":2336370,"end":2336689,"confidence":1,"speaker":"A"},{"text":"URLs","start":2336689,"end":2337330,"confidence":0.94067,"speaker":"A"},{"text":"in","start":2337330,"end":2337490,"confidence":0.98828125,"speaker":"A"},{"text":"the","start":2337490,"end":2337650,"confidence":0.9897461,"speaker":"A"},{"text":"Slack","start":2337650,"end":2337970,"confidence":0.998291,"speaker":"A"},{"text":"or","start":2337970,"end":2338170,"confidence":0.9970703,"speaker":"A"},{"text":"something.","start":2338170,"end":2338490,"confidence":0.9995117,"speaker":"A"},{"text":"Let's","start":2339450,"end":2339890,"confidence":0.99853516,"speaker":"A"},{"text":"do","start":2339890,"end":2340050,"confidence":0.9790039,"speaker":"A"},{"text":"this","start":2340050,"end":2340250,"confidence":0.9975586,"speaker":"A"},{"text":"one.","start":2340250,"end":2340570,"confidence":0.99316406,"speaker":"A"},{"text":"So","start":2342410,"end":2342810,"confidence":0.8173828,"speaker":"A"},{"text":"this","start":2343930,"end":2344210,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2344210,"end":2344370,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2344370,"end":2344530,"confidence":0.9765625,"speaker":"A"},{"text":"Swift","start":2344530,"end":2344810,"confidence":0.9226074,"speaker":"A"},{"text":"package","start":2344810,"end":2345370,"confidence":0.99768066,"speaker":"A"},{"text":"for","start":2347060,"end":2347220,"confidence":0.97998047,"speaker":"A"},{"text":"Bushel.","start":2347220,"end":2347860,"confidence":0.9685872,"speaker":"A"},{"text":"It's","start":2347860,"end":2348180,"confidence":0.9995117,"speaker":"A"},{"text":"called","start":2348180,"end":2348340,"confidence":0.99853516,"speaker":"A"},{"text":"Bushel","start":2348340,"end":2348780,"confidence":0.90283203,"speaker":"A"},{"text":"Cloud.","start":2348780,"end":2349180,"confidence":0.99658203,"speaker":"A"},{"text":"It","start":2349180,"end":2349420,"confidence":0.9995117,"speaker":"A"},{"text":"pulls","start":2349420,"end":2349700,"confidence":1,"speaker":"A"},{"text":"the","start":2349700,"end":2349820,"confidence":0.98828125,"speaker":"A"},{"text":"stuff","start":2349820,"end":2350060,"confidence":1,"speaker":"A"},{"text":"up","start":2350060,"end":2350300,"confidence":0.9995117,"speaker":"A"},{"text":"from.","start":2350300,"end":2350660,"confidence":0.9970703,"speaker":"A"},{"text":"Uses","start":2351220,"end":2351740,"confidence":0.84887695,"speaker":"A"},{"text":"Miskit","start":2351740,"end":2352340,"confidence":0.9329834,"speaker":"A"},{"text":"to","start":2353540,"end":2353820,"confidence":0.9941406,"speaker":"A"},{"text":"go","start":2353820,"end":2353980,"confidence":1,"speaker":"A"},{"text":"ahead","start":2353980,"end":2354260,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2354340,"end":2354740,"confidence":0.88720703,"speaker":"A"},{"text":"pull,","start":2356740,"end":2357220,"confidence":0.9621582,"speaker":"A"},{"text":"get","start":2357860,"end":2358140,"confidence":0.99902344,"speaker":"A"},{"text":"access","start":2358140,"end":2358380,"confidence":1,"speaker":"A"},{"text":"to","start":2358380,"end":2358700,"confidence":1,"speaker":"A"},{"text":"CloudKit","start":2358700,"end":2359460,"confidence":0.9325,"speaker":"A"},{"text":"and","start":2359940,"end":2360340,"confidence":0.98291016,"speaker":"A"},{"text":"let","start":2361060,"end":2361340,"confidence":0.99316406,"speaker":"A"},{"text":"me","start":2361340,"end":2361460,"confidence":1,"speaker":"A"},{"text":"go","start":2361460,"end":2361620,"confidence":0.9995117,"speaker":"A"},{"text":"back","start":2361620,"end":2361940,"confidence":1,"speaker":"A"},{"text":"to","start":2361940,"end":2362339,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2362339,"end":2362620,"confidence":1,"speaker":"A"},{"text":"workflow.","start":2362620,"end":2363300,"confidence":0.96276855,"speaker":"A"},{"text":"How","start":2364100,"end":2364420,"confidence":0.99853516,"speaker":"A"},{"text":"familiar","start":2364420,"end":2364860,"confidence":1,"speaker":"A"},{"text":"are","start":2364860,"end":2365020,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2365020,"end":2365180,"confidence":0.9995117,"speaker":"A"},{"text":"with","start":2365180,"end":2365380,"confidence":1,"speaker":"A"},{"text":"GitHub","start":2365380,"end":2365860,"confidence":0.87939453,"speaker":"A"},{"text":"workflows?","start":2365860,"end":2366580,"confidence":0.9026367,"speaker":"A"},{"text":"Sadly","start":2369860,"end":2370300,"confidence":0.99576825,"speaker":"C"},{"text":"not","start":2370300,"end":2370500,"confidence":0.9951172,"speaker":"C"},{"text":"had","start":2370500,"end":2370660,"confidence":0.9980469,"speaker":"C"},{"text":"the","start":2370660,"end":2370780,"confidence":0.99658203,"speaker":"C"},{"text":"chance","start":2370780,"end":2371020,"confidence":0.99975586,"speaker":"C"},{"text":"to","start":2371020,"end":2371180,"confidence":0.9995117,"speaker":"C"},{"text":"work","start":2371180,"end":2371460,"confidence":1,"speaker":"C"},{"text":"too","start":2371780,"end":2372060,"confidence":0.99560547,"speaker":"C"},{"text":"deeply","start":2372060,"end":2372380,"confidence":0.9991862,"speaker":"C"},{"text":"with","start":2372380,"end":2372500,"confidence":0.9995117,"speaker":"C"},{"text":"them","start":2372500,"end":2372660,"confidence":0.97021484,"speaker":"C"},{"text":"yet.","start":2372660,"end":2372980,"confidence":0.98291016,"speaker":"C"},{"text":"Okay.","start":2373690,"end":2374090,"confidence":0.9503581,"speaker":"A"},{"text":"Basically","start":2375130,"end":2375610,"confidence":0.9987793,"speaker":"A"},{"text":"it's","start":2375610,"end":2375850,"confidence":0.99934894,"speaker":"A"},{"text":"like","start":2375850,"end":2375970,"confidence":0.99072266,"speaker":"A"},{"text":"for","start":2375970,"end":2376170,"confidence":0.9448242,"speaker":"A"},{"text":"CI,","start":2376170,"end":2376610,"confidence":0.97021484,"speaker":"A"},{"text":"but","start":2376610,"end":2376810,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":2376810,"end":2376930,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2376930,"end":2377050,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":2377050,"end":2377250,"confidence":0.9995117,"speaker":"A"},{"text":"set","start":2377250,"end":2377490,"confidence":1,"speaker":"A"},{"text":"it","start":2377490,"end":2377610,"confidence":0.9995117,"speaker":"A"},{"text":"up","start":2377610,"end":2377730,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":2377730,"end":2377890,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2377890,"end":2378050,"confidence":0.9980469,"speaker":"A"},{"text":"schedule.","start":2378050,"end":2378570,"confidence":0.8905029,"speaker":"A"},{"text":"So","start":2378890,"end":2379170,"confidence":0.9941406,"speaker":"A"},{"text":"I","start":2379170,"end":2379330,"confidence":1,"speaker":"A"},{"text":"did","start":2379330,"end":2379530,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":2379530,"end":2379850,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2381290,"end":2381570,"confidence":0.9902344,"speaker":"A"},{"text":"then","start":2381570,"end":2381850,"confidence":0.9980469,"speaker":"A"},{"text":"it","start":2382890,"end":2383170,"confidence":0.99853516,"speaker":"A"},{"text":"runs","start":2383170,"end":2383490,"confidence":0.99975586,"speaker":"A"},{"text":"the","start":2383490,"end":2383610,"confidence":0.6640625,"speaker":"A"},{"text":"scheduled","start":2383610,"end":2384090,"confidence":0.89404297,"speaker":"A"},{"text":"job","start":2384090,"end":2384410,"confidence":1,"speaker":"A"},{"text":"and","start":2384810,"end":2385090,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":2385090,"end":2385250,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2385250,"end":2385450,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":2385450,"end":2385730,"confidence":0.9995117,"speaker":"A"},{"text":"execute.","start":2385730,"end":2386490,"confidence":0.97875977,"speaker":"A"},{"text":"So","start":2390650,"end":2390930,"confidence":0.9941406,"speaker":"A"},{"text":"then","start":2390930,"end":2391170,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":2391170,"end":2391410,"confidence":1,"speaker":"A"},{"text":"was","start":2391410,"end":2391610,"confidence":0.9995117,"speaker":"A"},{"text":"refactored","start":2391610,"end":2392490,"confidence":0.99283856,"speaker":"A"},{"text":"over","start":2393290,"end":2393690,"confidence":0.99560547,"speaker":"A"},{"text":"here","start":2393690,"end":2394090,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":2394330,"end":2394650,"confidence":0.9741211,"speaker":"A"},{"text":"an","start":2394650,"end":2394890,"confidence":0.99902344,"speaker":"A"},{"text":"action.","start":2394890,"end":2395210,"confidence":0.9995117,"speaker":"A"},{"text":"There","start":2397770,"end":2398090,"confidence":0.89990234,"speaker":"A"},{"text":"we","start":2398090,"end":2398250,"confidence":0.99853516,"speaker":"A"},{"text":"go.","start":2398250,"end":2398490,"confidence":0.99853516,"speaker":"A"},{"text":"And","start":2399540,"end":2399780,"confidence":0.9848633,"speaker":"A"},{"text":"I","start":2401140,"end":2401420,"confidence":0.99658203,"speaker":"A"},{"text":"have","start":2401420,"end":2401580,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2401580,"end":2401740,"confidence":0.9995117,"speaker":"A"},{"text":"sorts","start":2401740,"end":2402020,"confidence":0.890625,"speaker":"A"},{"text":"of","start":2402020,"end":2402180,"confidence":1,"speaker":"A"},{"text":"stuff","start":2402180,"end":2402380,"confidence":1,"speaker":"A"},{"text":"here","start":2402380,"end":2402660,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":2403060,"end":2403460,"confidence":0.9863281,"speaker":"A"},{"text":"like","start":2405380,"end":2405780,"confidence":0.97021484,"speaker":"A"},{"text":"this","start":2406660,"end":2406940,"confidence":0.9975586,"speaker":"A"},{"text":"is","start":2406940,"end":2407100,"confidence":0.99902344,"speaker":"A"},{"text":"generic","start":2407100,"end":2407700,"confidence":1,"speaker":"A"},{"text":"essentially,","start":2407700,"end":2408420,"confidence":0.9996338,"speaker":"A"},{"text":"but","start":2408500,"end":2408900,"confidence":0.9941406,"speaker":"A"},{"text":"all","start":2410020,"end":2410300,"confidence":0.98828125,"speaker":"A"},{"text":"these,","start":2410300,"end":2410580,"confidence":0.9868164,"speaker":"A"},{"text":"the","start":2410820,"end":2411140,"confidence":0.9223633,"speaker":"A"},{"text":"environment,","start":2411140,"end":2411460,"confidence":1,"speaker":"A"},{"text":"etc.","start":2411700,"end":2412500,"confidence":0.975,"speaker":"A"},{"text":"These","start":2413140,"end":2413420,"confidence":0.9995117,"speaker":"A"},{"text":"are","start":2413420,"end":2413540,"confidence":0.9995117,"speaker":"A"},{"text":"all","start":2413540,"end":2413700,"confidence":0.99853516,"speaker":"A"},{"text":"passed","start":2413700,"end":2414060,"confidence":0.93310547,"speaker":"A"},{"text":"from","start":2414060,"end":2414220,"confidence":1,"speaker":"A"},{"text":"that","start":2414220,"end":2414420,"confidence":0.99902344,"speaker":"A"},{"text":"workflow","start":2414420,"end":2414980,"confidence":0.9741211,"speaker":"A"},{"text":"into","start":2414980,"end":2415260,"confidence":0.99609375,"speaker":"A"},{"text":"here.","start":2415260,"end":2415620,"confidence":0.99902344,"speaker":"A"},{"text":"These","start":2415940,"end":2416220,"confidence":0.9975586,"speaker":"A"},{"text":"are","start":2416220,"end":2416380,"confidence":0.9995117,"speaker":"A"},{"text":"basically","start":2416380,"end":2416820,"confidence":0.9992676,"speaker":"A"},{"text":"either","start":2416820,"end":2417180,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":2417180,"end":2417620,"confidence":0.85180664,"speaker":"A"},{"text":"keys","start":2417620,"end":2417980,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":2417980,"end":2418180,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2418180,"end":2418420,"confidence":0.99902344,"speaker":"A"},{"text":"information","start":2418420,"end":2418740,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":2418820,"end":2419100,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2419100,"end":2419260,"confidence":1,"speaker":"A"},{"text":"need","start":2419260,"end":2419540,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":2419620,"end":2420020,"confidence":0.9995117,"speaker":"A"},{"text":"accessing","start":2420500,"end":2421100,"confidence":0.9953613,"speaker":"A"},{"text":"Cloud,","start":2421100,"end":2421460,"confidence":0.9243164,"speaker":"A"},{"text":"the","start":2421460,"end":2421780,"confidence":0.8491211,"speaker":"A"},{"text":"public,","start":2421780,"end":2422100,"confidence":0.765625,"speaker":"A"},{"text":"public","start":2424020,"end":2424380,"confidence":0.9995117,"speaker":"A"},{"text":"database.","start":2424380,"end":2425060,"confidence":0.99869794,"speaker":"A"},{"text":"Right.","start":2425840,"end":2426080,"confidence":0.9008789,"speaker":"A"},{"text":"And","start":2426480,"end":2426760,"confidence":0.9794922,"speaker":"A"},{"text":"then","start":2426760,"end":2427040,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":2427840,"end":2428120,"confidence":0.96435547,"speaker":"A"},{"text":"already","start":2428120,"end":2428360,"confidence":0.99902344,"speaker":"A"},{"text":"pre","start":2428360,"end":2428680,"confidence":0.99853516,"speaker":"A"},{"text":"built","start":2428680,"end":2429200,"confidence":0.8404948,"speaker":"A"},{"text":"the","start":2429760,"end":2430160,"confidence":0.9970703,"speaker":"A"},{"text":"binary.","start":2430160,"end":2430880,"confidence":0.9977214,"speaker":"A"},{"text":"So","start":2431120,"end":2431520,"confidence":0.99316406,"speaker":"A"},{"text":"we","start":2431600,"end":2431880,"confidence":0.9995117,"speaker":"A"},{"text":"already","start":2431880,"end":2432040,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2432040,"end":2432200,"confidence":0.99902344,"speaker":"A"},{"text":"that.","start":2432200,"end":2432360,"confidence":1,"speaker":"A"},{"text":"We're","start":2432360,"end":2432600,"confidence":0.9973958,"speaker":"A"},{"text":"running","start":2432600,"end":2432840,"confidence":1,"speaker":"A"},{"text":"this","start":2432840,"end":2433120,"confidence":0.99902344,"speaker":"A"},{"text":"on","start":2433200,"end":2433600,"confidence":0.9975586,"speaker":"A"},{"text":"Ubuntu","start":2434880,"end":2435720,"confidence":0.93408203,"speaker":"A"},{"text":"because","start":2435720,"end":2435960,"confidence":0.94970703,"speaker":"A"},{"text":"it's","start":2435960,"end":2436160,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2436160,"end":2436280,"confidence":0.8647461,"speaker":"A"},{"text":"default.","start":2436280,"end":2436800,"confidence":0.9998779,"speaker":"A"},{"text":"Look","start":2437200,"end":2437480,"confidence":0.9970703,"speaker":"A"},{"text":"at","start":2437480,"end":2437640,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":2437640,"end":2437920,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":2439200,"end":2439600,"confidence":0.9980469,"speaker":"A"},{"text":"there","start":2439920,"end":2440280,"confidence":1,"speaker":"A"},{"text":"is","start":2440280,"end":2440560,"confidence":0.9995117,"speaker":"A"},{"text":"no","start":2440560,"end":2440880,"confidence":0.9970703,"speaker":"A"},{"text":"binary,","start":2440960,"end":2441639,"confidence":0.9977214,"speaker":"A"},{"text":"it","start":2441639,"end":2441840,"confidence":0.9736328,"speaker":"A"},{"text":"goes","start":2441840,"end":2442000,"confidence":1,"speaker":"A"},{"text":"ahead","start":2442000,"end":2442120,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2442120,"end":2442320,"confidence":1,"speaker":"A"},{"text":"builds","start":2442320,"end":2442680,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":2442680,"end":2442800,"confidence":1,"speaker":"A"},{"text":"binary","start":2442800,"end":2443280,"confidence":0.9991862,"speaker":"A"},{"text":"for","start":2443280,"end":2443520,"confidence":0.99853516,"speaker":"A"},{"text":"me.","start":2443520,"end":2443840,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2444000,"end":2444240,"confidence":0.95166016,"speaker":"A"},{"text":"that's","start":2444240,"end":2444400,"confidence":0.9991862,"speaker":"A"},{"text":"what","start":2444400,"end":2444520,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2444520,"end":2444680,"confidence":1,"speaker":"A"},{"text":"is","start":2444680,"end":2444880,"confidence":1,"speaker":"A"},{"text":"doing.","start":2444880,"end":2445200,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":2447120,"end":2447440,"confidence":0.88671875,"speaker":"A"},{"text":"then","start":2447440,"end":2447760,"confidence":0.99902344,"speaker":"A"},{"text":"we","start":2448800,"end":2449080,"confidence":0.9995117,"speaker":"A"},{"text":"make","start":2449080,"end":2449280,"confidence":0.7973633,"speaker":"A"},{"text":"sure","start":2449280,"end":2449480,"confidence":1,"speaker":"A"},{"text":"the","start":2449480,"end":2449640,"confidence":0.9941406,"speaker":"A"},{"text":"binary","start":2449640,"end":2450080,"confidence":0.92838544,"speaker":"A"},{"text":"works.","start":2450080,"end":2450640,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":2450880,"end":2451120,"confidence":0.41552734,"speaker":"A"},{"text":"make,","start":2451120,"end":2451180,"confidence":0.6088867,"speaker":"A"},{"text":"we","start":2451250,"end":2451330,"confidence":0.6176758,"speaker":"A"},{"text":"make","start":2451330,"end":2451450,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":2451450,"end":2451610,"confidence":0.9550781,"speaker":"A"},{"text":"executable,","start":2451610,"end":2452210,"confidence":0.9968262,"speaker":"A"},{"text":"we","start":2452290,"end":2452650,"confidence":0.99658203,"speaker":"A"},{"text":"validate,","start":2452650,"end":2453290,"confidence":0.9996745,"speaker":"A"},{"text":"make","start":2453290,"end":2453530,"confidence":0.9951172,"speaker":"A"},{"text":"sure","start":2453530,"end":2453730,"confidence":1,"speaker":"A"},{"text":"all","start":2453730,"end":2454050,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2454050,"end":2454450,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":2455010,"end":2455570,"confidence":0.9987793,"speaker":"A"},{"text":"secrets","start":2455570,"end":2456050,"confidence":0.98339844,"speaker":"A"},{"text":"are","start":2456050,"end":2456250,"confidence":0.99902344,"speaker":"A"},{"text":"there.","start":2456250,"end":2456530,"confidence":0.99902344,"speaker":"A"},{"text":"We","start":2457650,"end":2457970,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":2457970,"end":2458210,"confidence":0.99658203,"speaker":"A"},{"text":"go","start":2458210,"end":2458410,"confidence":0.99853516,"speaker":"A"},{"text":"ahead","start":2458410,"end":2458690,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2458930,"end":2459290,"confidence":0.9921875,"speaker":"A"},{"text":"this","start":2459290,"end":2459530,"confidence":0.9863281,"speaker":"A"},{"text":"validates","start":2459530,"end":2460010,"confidence":0.99690753,"speaker":"A"},{"text":"the","start":2460010,"end":2460170,"confidence":0.99902344,"speaker":"A"},{"text":"pim.","start":2460170,"end":2460530,"confidence":0.8864746,"speaker":"A"},{"text":"But","start":2460690,"end":2460970,"confidence":0.99853516,"speaker":"A"},{"text":"essentially","start":2460970,"end":2461370,"confidence":0.9954834,"speaker":"A"},{"text":"this","start":2461370,"end":2461530,"confidence":0.9902344,"speaker":"A"},{"text":"is","start":2461530,"end":2461650,"confidence":0.9814453,"speaker":"A"},{"text":"the","start":2461650,"end":2461770,"confidence":0.8173828,"speaker":"A"},{"text":"fun","start":2461770,"end":2462010,"confidence":0.9980469,"speaker":"A"},{"text":"part.","start":2462010,"end":2462370,"confidence":0.9995117,"speaker":"A"},{"text":"We","start":2463410,"end":2463690,"confidence":0.9995117,"speaker":"A"},{"text":"go","start":2463690,"end":2463810,"confidence":0.9995117,"speaker":"A"},{"text":"ahead,","start":2463810,"end":2464050,"confidence":0.99902344,"speaker":"A"},{"text":"we","start":2464050,"end":2464330,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":2464330,"end":2464610,"confidence":0.99902344,"speaker":"A"},{"text":"all","start":2464930,"end":2465290,"confidence":0.99853516,"speaker":"A"},{"text":"our","start":2465290,"end":2465530,"confidence":0.99365234,"speaker":"A"},{"text":"inputs","start":2465530,"end":2466010,"confidence":0.88171387,"speaker":"A"},{"text":"for","start":2466010,"end":2466170,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2466170,"end":2466290,"confidence":1,"speaker":"A"},{"text":"private","start":2466290,"end":2466490,"confidence":0.99902344,"speaker":"A"},{"text":"key,","start":2466490,"end":2466770,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2466770,"end":2467089,"confidence":0.9277344,"speaker":"A"},{"text":"key","start":2467089,"end":2467410,"confidence":0.98779297,"speaker":"A"},{"text":"id,","start":2467410,"end":2467730,"confidence":0.97021484,"speaker":"A"},{"text":"environment,","start":2467810,"end":2468210,"confidence":0.99902344,"speaker":"A"},{"text":"container","start":2468690,"end":2469290,"confidence":0.99902344,"speaker":"A"},{"text":"id.","start":2469290,"end":2469570,"confidence":0.99609375,"speaker":"A"},{"text":"And","start":2470610,"end":2470890,"confidence":0.9707031,"speaker":"A"},{"text":"then","start":2470890,"end":2471050,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2471050,"end":2471170,"confidence":0.99902344,"speaker":"A"},{"text":"use","start":2471170,"end":2471370,"confidence":0.99658203,"speaker":"A"},{"text":"Virtual","start":2471370,"end":2471770,"confidence":0.9996338,"speaker":"A"},{"text":"Buddy","start":2471770,"end":2472090,"confidence":0.98583984,"speaker":"A"},{"text":"for","start":2472090,"end":2472250,"confidence":0.99902344,"speaker":"A"},{"text":"signing","start":2472250,"end":2472650,"confidence":0.9938965,"speaker":"A"},{"text":"verification.","start":2472650,"end":2473410,"confidence":0.99990237,"speaker":"A"},{"text":"And.","start":2474050,"end":2474450,"confidence":0.93603516,"speaker":"A"},{"text":"It","start":2478460,"end":2478580,"confidence":0.9707031,"speaker":"A"},{"text":"then","start":2478580,"end":2478740,"confidence":0.9980469,"speaker":"A"},{"text":"goes","start":2478740,"end":2479060,"confidence":0.99975586,"speaker":"A"},{"text":"in","start":2479060,"end":2479220,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2479220,"end":2479500,"confidence":0.8173828,"speaker":"A"},{"text":"it","start":2479900,"end":2480300,"confidence":0.99560547,"speaker":"A"},{"text":"runs","start":2481260,"end":2481740,"confidence":1,"speaker":"A"},{"text":"the","start":2481740,"end":2481940,"confidence":0.9995117,"speaker":"A"},{"text":"sync","start":2481940,"end":2482380,"confidence":0.9733073,"speaker":"A"},{"text":"and","start":2483500,"end":2483780,"confidence":0.96435547,"speaker":"A"},{"text":"then","start":2483780,"end":2484060,"confidence":0.97753906,"speaker":"A"},{"text":"we'll","start":2484860,"end":2485220,"confidence":0.8601888,"speaker":"A"},{"text":"go","start":2485220,"end":2485380,"confidence":0.99902344,"speaker":"A"},{"text":"in.","start":2485380,"end":2485660,"confidence":0.9980469,"speaker":"A"},{"text":"Basically","start":2485980,"end":2486460,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2486460,"end":2486620,"confidence":0.95996094,"speaker":"A"},{"text":"pulls","start":2486620,"end":2486900,"confidence":0.99902344,"speaker":"A"},{"text":"from","start":2486900,"end":2487060,"confidence":1,"speaker":"A"},{"text":"several","start":2487060,"end":2487340,"confidence":0.9995117,"speaker":"A"},{"text":"websites","start":2487340,"end":2488140,"confidence":0.99658203,"speaker":"A"},{"text":"information","start":2489100,"end":2489500,"confidence":1,"speaker":"A"},{"text":"about","start":2489580,"end":2489900,"confidence":0.9995117,"speaker":"A"},{"text":"macrosos,","start":2489900,"end":2490500,"confidence":0.85645,"speaker":"A"},{"text":"restore","start":2490500,"end":2490940,"confidence":0.85498047,"speaker":"A"},{"text":"images","start":2490940,"end":2491380,"confidence":0.998291,"speaker":"A"},{"text":"and","start":2491380,"end":2491620,"confidence":0.9980469,"speaker":"A"},{"text":"checks","start":2491620,"end":2491940,"confidence":0.9996745,"speaker":"A"},{"text":"whether","start":2491940,"end":2492100,"confidence":0.99902344,"speaker":"A"},{"text":"they're","start":2492100,"end":2492380,"confidence":0.98030597,"speaker":"A"},{"text":"signed.","start":2492380,"end":2492939,"confidence":0.80981445,"speaker":"A"},{"text":"And","start":2493340,"end":2493620,"confidence":0.94970703,"speaker":"A"},{"text":"then","start":2493620,"end":2493780,"confidence":0.9970703,"speaker":"A"},{"text":"it","start":2493780,"end":2493940,"confidence":1,"speaker":"A"},{"text":"goes","start":2493940,"end":2494140,"confidence":1,"speaker":"A"},{"text":"ahead","start":2494140,"end":2494340,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2494340,"end":2494700,"confidence":0.53125,"speaker":"A"},{"text":"it","start":2494780,"end":2495180,"confidence":0.86621094,"speaker":"A"},{"text":"adds","start":2496380,"end":2496900,"confidence":0.99853516,"speaker":"A"},{"text":"those","start":2496900,"end":2497180,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2497260,"end":2497540,"confidence":1,"speaker":"A"},{"text":"the","start":2497540,"end":2497660,"confidence":1,"speaker":"A"},{"text":"database.","start":2497660,"end":2498260,"confidence":0.9998372,"speaker":"A"},{"text":"And","start":2498260,"end":2498500,"confidence":0.9238281,"speaker":"A"},{"text":"then","start":2498500,"end":2498700,"confidence":0.9902344,"speaker":"A"},{"text":"what","start":2498700,"end":2498900,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2498900,"end":2499060,"confidence":1,"speaker":"A"},{"text":"does","start":2499060,"end":2499260,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2499260,"end":2499460,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":2499460,"end":2499620,"confidence":0.86279297,"speaker":"A"},{"text":"exports","start":2499620,"end":2500140,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2500620,"end":2500940,"confidence":0.99560547,"speaker":"A"},{"text":"information","start":2500940,"end":2501260,"confidence":1,"speaker":"A"},{"text":"in","start":2501500,"end":2501780,"confidence":0.9946289,"speaker":"A"},{"text":"a","start":2501780,"end":2501900,"confidence":0.98046875,"speaker":"A"},{"text":"run.","start":2501900,"end":2502100,"confidence":0.9926758,"speaker":"A"},{"text":"Let's,","start":2502100,"end":2502460,"confidence":0.7273763,"speaker":"A"},{"text":"let's","start":2502460,"end":2502700,"confidence":0.8728841,"speaker":"A"},{"text":"take","start":2502700,"end":2502820,"confidence":0.9921875,"speaker":"A"},{"text":"a","start":2502820,"end":2502940,"confidence":1,"speaker":"A"},{"text":"look,","start":2502940,"end":2503140,"confidence":0.9995117,"speaker":"A"},{"text":"see","start":2503140,"end":2503380,"confidence":0.99902344,"speaker":"A"},{"text":"if","start":2503380,"end":2503500,"confidence":1,"speaker":"A"},{"text":"I","start":2503500,"end":2503580,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2503580,"end":2503740,"confidence":0.9995117,"speaker":"A"},{"text":"one.","start":2503740,"end":2504020,"confidence":0.9863281,"speaker":"A"},{"text":"I","start":2504020,"end":2504260,"confidence":0.99316406,"speaker":"A"},{"text":"can","start":2504260,"end":2504420,"confidence":0.9458008,"speaker":"A"},{"text":"show","start":2504420,"end":2504580,"confidence":0.9995117,"speaker":"A"},{"text":"you.","start":2504580,"end":2504860,"confidence":0.9970703,"speaker":"A"},{"text":"Oh,","start":2505980,"end":2506180,"confidence":0.8977051,"speaker":"A"},{"text":"there's","start":2506180,"end":2506460,"confidence":0.91503906,"speaker":"A"},{"text":"one","start":2506460,"end":2506700,"confidence":0.99853516,"speaker":"A"},{"text":"scheduled.","start":2506700,"end":2507420,"confidence":0.97436523,"speaker":"A"},{"text":"Yeah,","start":2510060,"end":2510460,"confidence":0.97347003,"speaker":"A"},{"text":"here","start":2510460,"end":2510660,"confidence":0.9995117,"speaker":"A"},{"text":"we","start":2510660,"end":2510780,"confidence":1,"speaker":"A"},{"text":"go.","start":2510780,"end":2511020,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2511260,"end":2511660,"confidence":0.8173828,"speaker":"A"},{"text":"there's","start":2512060,"end":2512700,"confidence":0.9090169,"speaker":"A"},{"text":"57","start":2513100,"end":2513700,"confidence":0.99829,"speaker":"A"},{"text":"new","start":2513700,"end":2514060,"confidence":0.98291016,"speaker":"A"},{"text":"restore","start":2514060,"end":2514580,"confidence":0.84936523,"speaker":"A"},{"text":"images","start":2514580,"end":2514980,"confidence":0.9980469,"speaker":"A"},{"text":"created,","start":2514980,"end":2515580,"confidence":0.9970703,"speaker":"A"},{"text":"177","start":2516300,"end":2517500,"confidence":0.95771,"speaker":"A"},{"text":"updated.","start":2517660,"end":2518300,"confidence":0.9980469,"speaker":"A"},{"text":"234","start":2518780,"end":2519900,"confidence":0.93447,"speaker":"A"},{"text":"total.","start":2519980,"end":2520380,"confidence":0.9995117,"speaker":"A"},{"text":"No","start":2521420,"end":2521740,"confidence":0.9970703,"speaker":"A"},{"text":"operations","start":2521740,"end":2522300,"confidence":0.9987793,"speaker":"A"},{"text":"failed.","start":2522380,"end":2523020,"confidence":0.9992676,"speaker":"A"},{"text":"I","start":2523100,"end":2523380,"confidence":0.9916992,"speaker":"A"},{"text":"also","start":2523380,"end":2523580,"confidence":0.99902344,"speaker":"A"},{"text":"store","start":2523580,"end":2523900,"confidence":0.77490234,"speaker":"A"},{"text":"Xcode","start":2523900,"end":2524340,"confidence":0.89245605,"speaker":"A"},{"text":"versions","start":2524340,"end":2524700,"confidence":0.9970703,"speaker":"A"},{"text":"and","start":2524700,"end":2524980,"confidence":0.9370117,"speaker":"A"},{"text":"Swift","start":2524980,"end":2525420,"confidence":0.9921875,"speaker":"A"},{"text":"versions.","start":2525420,"end":2525900,"confidence":0.9975586,"speaker":"A"},{"text":"Those","start":2526780,"end":2527100,"confidence":0.99853516,"speaker":"A"},{"text":"get","start":2527100,"end":2527300,"confidence":0.99902344,"speaker":"A"},{"text":"stored","start":2527300,"end":2527620,"confidence":0.99853516,"speaker":"A"},{"text":"as","start":2527620,"end":2527780,"confidence":0.9995117,"speaker":"A"},{"text":"well.","start":2527780,"end":2528060,"confidence":0.9995117,"speaker":"A"},{"text":"Had","start":2529420,"end":2529700,"confidence":0.89697266,"speaker":"A"},{"text":"to","start":2529700,"end":2529860,"confidence":0.9736328,"speaker":"A"},{"text":"rebuild","start":2529860,"end":2530180,"confidence":0.9995117,"speaker":"A"},{"text":"it,","start":2530180,"end":2530460,"confidence":0.9975586,"speaker":"A"},{"text":"but","start":2530630,"end":2530790,"confidence":0.99902344,"speaker":"A"},{"text":"here","start":2530790,"end":2531070,"confidence":1,"speaker":"A"},{"text":"is","start":2531070,"end":2531310,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2531310,"end":2531510,"confidence":1,"speaker":"A"},{"text":"results.","start":2531510,"end":2531830,"confidence":0.98046875,"speaker":"A"},{"text":"I'm","start":2533750,"end":2534070,"confidence":0.9995117,"speaker":"A"},{"text":"not","start":2534070,"end":2534190,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":2534190,"end":2534310,"confidence":0.9140625,"speaker":"A"},{"text":"to","start":2534310,"end":2534390,"confidence":0.9995117,"speaker":"A"},{"text":"pull","start":2534390,"end":2534590,"confidence":0.99975586,"speaker":"A"},{"text":"that","start":2534590,"end":2534750,"confidence":0.99853516,"speaker":"A"},{"text":"up,","start":2534750,"end":2535030,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":2535830,"end":2536110,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":2536110,"end":2536350,"confidence":0.9944661,"speaker":"A"},{"text":"essentially","start":2536350,"end":2536950,"confidence":0.9980469,"speaker":"A"},{"text":"updated","start":2537270,"end":2537750,"confidence":0.99853516,"speaker":"A"},{"text":"my","start":2537750,"end":2537990,"confidence":0.99609375,"speaker":"A"},{"text":"CloudKit","start":2537990,"end":2538710,"confidence":0.9953613,"speaker":"A"},{"text":"database","start":2538790,"end":2539510,"confidence":0.99902344,"speaker":"A"},{"text":"and","start":2542070,"end":2542470,"confidence":0.99658203,"speaker":"A"},{"text":"that's","start":2542550,"end":2542950,"confidence":0.9998372,"speaker":"A"},{"text":"all","start":2542950,"end":2543070,"confidence":0.9995117,"speaker":"A"},{"text":"in","start":2543070,"end":2543190,"confidence":0.9892578,"speaker":"A"},{"text":"the","start":2543190,"end":2543310,"confidence":0.99902344,"speaker":"A"},{"text":"public","start":2543310,"end":2543510,"confidence":1,"speaker":"A"},{"text":"database.","start":2543510,"end":2544030,"confidence":0.9991862,"speaker":"A"},{"text":"And","start":2544030,"end":2544150,"confidence":0.9980469,"speaker":"A"},{"text":"then","start":2544150,"end":2544390,"confidence":0.9980469,"speaker":"A"},{"text":"maybe","start":2545110,"end":2545470,"confidence":0.99975586,"speaker":"A"},{"text":"even","start":2545470,"end":2545670,"confidence":0.9995117,"speaker":"A"},{"text":"by","start":2545670,"end":2545870,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2545870,"end":2546030,"confidence":0.9995117,"speaker":"A"},{"text":"time","start":2546030,"end":2546190,"confidence":1,"speaker":"A"},{"text":"I","start":2546190,"end":2546310,"confidence":0.99560547,"speaker":"A"},{"text":"present","start":2546310,"end":2546550,"confidence":0.9995117,"speaker":"A"},{"text":"this,","start":2546550,"end":2546869,"confidence":0.9995117,"speaker":"A"},{"text":"I'll","start":2546869,"end":2547110,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2547110,"end":2547310,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":2547310,"end":2547550,"confidence":0.97314453,"speaker":"A"},{"text":"working","start":2547550,"end":2547830,"confidence":0.99902344,"speaker":"A"},{"text":"example","start":2547830,"end":2548350,"confidence":0.9814453,"speaker":"A"},{"text":"in","start":2548350,"end":2548510,"confidence":0.7578125,"speaker":"A"},{"text":"Bushel","start":2548510,"end":2548950,"confidence":0.9241536,"speaker":"A"},{"text":"with","start":2548950,"end":2549150,"confidence":1,"speaker":"A"},{"text":"that","start":2549150,"end":2549390,"confidence":0.9975586,"speaker":"A"},{"text":"example","start":2549390,"end":2549910,"confidence":0.9869792,"speaker":"A"},{"text":"working,","start":2549910,"end":2550230,"confidence":0.99902344,"speaker":"A"},{"text":"which","start":2550630,"end":2550910,"confidence":0.93310547,"speaker":"A"},{"text":"would","start":2550910,"end":2551070,"confidence":0.9277344,"speaker":"A"},{"text":"be","start":2551070,"end":2551230,"confidence":0.9995117,"speaker":"A"},{"text":"awesome.","start":2551230,"end":2551670,"confidence":0.99886066,"speaker":"A"},{"text":"Celestra,","start":2552870,"end":2553750,"confidence":0.7898763,"speaker":"A"},{"text":"same","start":2553990,"end":2554310,"confidence":0.99853516,"speaker":"A"},{"text":"idea.","start":2554310,"end":2554870,"confidence":0.998291,"speaker":"A"},{"text":"So","start":2555030,"end":2555310,"confidence":0.9970703,"speaker":"A"},{"text":"this","start":2555310,"end":2555470,"confidence":0.9916992,"speaker":"A"},{"text":"looks","start":2555470,"end":2555670,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":2555670,"end":2555790,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2555790,"end":2555910,"confidence":0.9824219,"speaker":"A"},{"text":"was","start":2555910,"end":2555990,"confidence":0.9975586,"speaker":"A"},{"text":"a","start":2555990,"end":2556110,"confidence":0.80810547,"speaker":"A"},{"text":"RSS","start":2556110,"end":2556630,"confidence":0.72924805,"speaker":"A"},{"text":"update.","start":2556630,"end":2557190,"confidence":0.9975586,"speaker":"A"},{"text":"We","start":2558910,"end":2559030,"confidence":0.9663086,"speaker":"A"},{"text":"get","start":2559030,"end":2559150,"confidence":0.5415039,"speaker":"A"},{"text":"the","start":2559150,"end":2559270,"confidence":0.9970703,"speaker":"A"},{"text":"workflow","start":2559270,"end":2559790,"confidence":0.9992676,"speaker":"A"},{"text":"file","start":2559790,"end":2560190,"confidence":0.79589844,"speaker":"A"},{"text":"and.","start":2562510,"end":2562830,"confidence":0.8984375,"speaker":"A"},{"text":"Oh,","start":2562830,"end":2563150,"confidence":0.78930664,"speaker":"A"},{"text":"sorry,","start":2563150,"end":2563430,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2563430,"end":2563590,"confidence":0.99902344,"speaker":"A"},{"text":"should","start":2563590,"end":2563830,"confidence":0.9995117,"speaker":"A"},{"text":"point","start":2563830,"end":2564070,"confidence":1,"speaker":"A"},{"text":"out,","start":2564070,"end":2564270,"confidence":1,"speaker":"A"},{"text":"because","start":2564270,"end":2564470,"confidence":0.96191406,"speaker":"A"},{"text":"you're","start":2564470,"end":2564670,"confidence":0.9991862,"speaker":"A"},{"text":"probably","start":2564670,"end":2564870,"confidence":1,"speaker":"A"},{"text":"wondering","start":2564870,"end":2565270,"confidence":0.99121094,"speaker":"A"},{"text":"where","start":2565270,"end":2565510,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2565510,"end":2565670,"confidence":0.88183594,"speaker":"A"},{"text":"all","start":2565670,"end":2565830,"confidence":0.99121094,"speaker":"A"},{"text":"these.","start":2565830,"end":2566110,"confidence":0.8798828,"speaker":"A"},{"text":"The","start":2566110,"end":2566390,"confidence":0.8417969,"speaker":"A"},{"text":"stuff","start":2566390,"end":2566710,"confidence":0.99853516,"speaker":"A"},{"text":"all","start":2566710,"end":2566950,"confidence":0.9892578,"speaker":"A"},{"text":"these","start":2566950,"end":2567110,"confidence":0.7866211,"speaker":"A"},{"text":"secrets","start":2567110,"end":2567510,"confidence":0.97875977,"speaker":"A"},{"text":"stored?","start":2567510,"end":2567870,"confidence":0.98657227,"speaker":"A"},{"text":"Yes,","start":2567870,"end":2568150,"confidence":0.99975586,"speaker":"A"},{"text":"they","start":2568150,"end":2568310,"confidence":0.99902344,"speaker":"A"},{"text":"are","start":2568310,"end":2568510,"confidence":0.99902344,"speaker":"A"},{"text":"stored","start":2568510,"end":2568990,"confidence":0.99731445,"speaker":"A"},{"text":"in","start":2569790,"end":2570150,"confidence":0.9765625,"speaker":"A"},{"text":"Actions","start":2570150,"end":2570830,"confidence":0.9909668,"speaker":"A"},{"text":"secrets","start":2570990,"end":2571790,"confidence":0.998291,"speaker":"A"},{"text":"right","start":2572430,"end":2572750,"confidence":0.99853516,"speaker":"A"},{"text":"here.","start":2572750,"end":2573070,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2573310,"end":2573589,"confidence":0.94384766,"speaker":"A"},{"text":"we","start":2573589,"end":2573750,"confidence":1,"speaker":"A"},{"text":"have","start":2573750,"end":2573910,"confidence":1,"speaker":"A"},{"text":"our","start":2573910,"end":2574070,"confidence":0.8671875,"speaker":"A"},{"text":"private","start":2574070,"end":2574310,"confidence":0.9995117,"speaker":"A"},{"text":"key","start":2574310,"end":2574670,"confidence":0.9980469,"speaker":"A"},{"text":"ID","start":2575310,"end":2575710,"confidence":0.8774414,"speaker":"A"},{"text":"API","start":2576510,"end":2577070,"confidence":0.98535156,"speaker":"A"},{"text":"key","start":2577070,"end":2577390,"confidence":0.9970703,"speaker":"A"},{"text":"from","start":2577790,"end":2578190,"confidence":0.9995117,"speaker":"A"},{"text":"Virtual","start":2578190,"end":2578670,"confidence":0.99975586,"speaker":"A"},{"text":"Buddy.","start":2578670,"end":2579150,"confidence":0.97786456,"speaker":"A"},{"text":"So","start":2579550,"end":2579950,"confidence":0.9667969,"speaker":"A"},{"text":"that's","start":2580030,"end":2580430,"confidence":0.99625653,"speaker":"A"},{"text":"all","start":2580430,"end":2580550,"confidence":0.98779297,"speaker":"A"},{"text":"stored","start":2580550,"end":2580950,"confidence":0.9921875,"speaker":"A"},{"text":"there.","start":2580950,"end":2581230,"confidence":0.99658203,"speaker":"A"},{"text":"Here","start":2581870,"end":2582270,"confidence":0.99853516,"speaker":"A"},{"text":"is","start":2582350,"end":2582750,"confidence":0.9975586,"speaker":"A"},{"text":"Celestra.","start":2583150,"end":2583950,"confidence":0.8902995,"speaker":"A"},{"text":"It's","start":2584270,"end":2584710,"confidence":0.99886066,"speaker":"A"},{"text":"for","start":2584710,"end":2584910,"confidence":0.99902344,"speaker":"A"},{"text":"updating","start":2584910,"end":2585350,"confidence":0.9995117,"speaker":"A"},{"text":"RSS","start":2585350,"end":2585830,"confidence":0.9616699,"speaker":"A"},{"text":"feeds.","start":2585830,"end":2586350,"confidence":0.9967448,"speaker":"A"},{"text":"So","start":2587050,"end":2587130,"confidence":0.97216797,"speaker":"A"},{"text":"it","start":2587130,"end":2587210,"confidence":0.9663086,"speaker":"A"},{"text":"just","start":2587210,"end":2587370,"confidence":0.9951172,"speaker":"A"},{"text":"basically","start":2587370,"end":2587810,"confidence":0.99975586,"speaker":"A"},{"text":"goes","start":2587810,"end":2588170,"confidence":0.9995117,"speaker":"A"},{"text":"through.","start":2588170,"end":2588490,"confidence":0.9995117,"speaker":"A"},{"text":"You","start":2588570,"end":2588810,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2588810,"end":2588930,"confidence":0.9995117,"speaker":"A"},{"text":"look","start":2588930,"end":2589090,"confidence":1,"speaker":"A"},{"text":"at","start":2589090,"end":2589210,"confidence":1,"speaker":"A"},{"text":"the","start":2589210,"end":2589290,"confidence":0.9951172,"speaker":"A"},{"text":"Swift","start":2589290,"end":2589610,"confidence":0.99902344,"speaker":"A"},{"text":"code","start":2589610,"end":2589930,"confidence":0.976888,"speaker":"A"},{"text":"it","start":2589930,"end":2590130,"confidence":0.9995117,"speaker":"A"},{"text":"goes","start":2590130,"end":2590370,"confidence":0.9995117,"speaker":"A"},{"text":"through,","start":2590370,"end":2590610,"confidence":0.9995117,"speaker":"A"},{"text":"pulls","start":2590610,"end":2590970,"confidence":0.97249347,"speaker":"A"},{"text":"RSS","start":2590970,"end":2591370,"confidence":0.98217773,"speaker":"A"},{"text":"feeds","start":2591370,"end":2591890,"confidence":0.9975586,"speaker":"A"},{"text":"and","start":2591890,"end":2592090,"confidence":0.9975586,"speaker":"A"},{"text":"updates","start":2592090,"end":2592650,"confidence":0.9995117,"speaker":"A"},{"text":"them","start":2593050,"end":2593370,"confidence":0.98876953,"speaker":"A"},{"text":"into","start":2593370,"end":2593650,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2593650,"end":2593850,"confidence":0.9970703,"speaker":"A"},{"text":"CloudKit","start":2593850,"end":2594490,"confidence":0.9980469,"speaker":"A"},{"text":"record","start":2595530,"end":2595930,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":2596410,"end":2596810,"confidence":0.9975586,"speaker":"A"},{"text":"what","start":2596890,"end":2597130,"confidence":0.9321289,"speaker":"A"},{"text":"do","start":2597130,"end":2597210,"confidence":0.8364258,"speaker":"A"},{"text":"you","start":2597210,"end":2597290,"confidence":0.9980469,"speaker":"A"},{"text":"call","start":2597290,"end":2597370,"confidence":1,"speaker":"A"},{"text":"it?","start":2597370,"end":2597490,"confidence":0.9951172,"speaker":"A"},{"text":"Yeah,","start":2597490,"end":2597730,"confidence":0.9558919,"speaker":"A"},{"text":"record","start":2597730,"end":2598010,"confidence":0.99853516,"speaker":"A"},{"text":"type.","start":2598010,"end":2598490,"confidence":0.9250488,"speaker":"A"},{"text":"And","start":2599850,"end":2600130,"confidence":0.9638672,"speaker":"A"},{"text":"I","start":2600130,"end":2600290,"confidence":0.9946289,"speaker":"A"},{"text":"of","start":2600290,"end":2600410,"confidence":0.64501953,"speaker":"A"},{"text":"course","start":2600410,"end":2600570,"confidence":0.9995117,"speaker":"A"},{"text":"try","start":2600570,"end":2600770,"confidence":0.9506836,"speaker":"A"},{"text":"to","start":2600770,"end":2600890,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":2600890,"end":2600970,"confidence":1,"speaker":"A"},{"text":"it","start":2600970,"end":2601050,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":2601050,"end":2601130,"confidence":0.98876953,"speaker":"A"},{"text":"such","start":2601130,"end":2601250,"confidence":1,"speaker":"A"},{"text":"a","start":2601250,"end":2601370,"confidence":0.96777344,"speaker":"A"},{"text":"way","start":2601370,"end":2601530,"confidence":1,"speaker":"A"},{"text":"not","start":2601530,"end":2601730,"confidence":0.99365234,"speaker":"A"},{"text":"to","start":2601730,"end":2601890,"confidence":0.9980469,"speaker":"A"},{"text":"hammer","start":2601890,"end":2602210,"confidence":0.9998372,"speaker":"A"},{"text":"people,","start":2602210,"end":2602490,"confidence":0.9995117,"speaker":"A"},{"text":"but","start":2602970,"end":2603370,"confidence":0.9902344,"speaker":"A"},{"text":"same","start":2603370,"end":2603690,"confidence":0.9941406,"speaker":"A"},{"text":"idea,","start":2603690,"end":2604170,"confidence":0.9914551,"speaker":"A"},{"text":"yeah,","start":2607050,"end":2607410,"confidence":0.96761066,"speaker":"A"},{"text":"it","start":2607410,"end":2607570,"confidence":0.99902344,"speaker":"A"},{"text":"goes","start":2607570,"end":2607770,"confidence":1,"speaker":"A"},{"text":"ahead","start":2607770,"end":2608010,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2608010,"end":2608330,"confidence":0.9921875,"speaker":"A"},{"text":"it","start":2608330,"end":2608570,"confidence":0.98828125,"speaker":"A"},{"text":"runs","start":2608570,"end":2609130,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2610330,"end":2610610,"confidence":0.9995117,"speaker":"A"},{"text":"binary","start":2610610,"end":2611210,"confidence":0.9991862,"speaker":"A"},{"text":"it","start":2611210,"end":2611530,"confidence":0.9711914,"speaker":"A"},{"text":"updates","start":2611530,"end":2612010,"confidence":0.9992676,"speaker":"A"},{"text":"and","start":2612170,"end":2612410,"confidence":0.98828125,"speaker":"A"},{"text":"then","start":2612410,"end":2612570,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2612570,"end":2612770,"confidence":0.9995117,"speaker":"A"},{"text":"also","start":2612770,"end":2612970,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":2612970,"end":2613290,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":2613290,"end":2613650,"confidence":0.9321289,"speaker":"A"},{"text":"actual","start":2613650,"end":2614170,"confidence":0.99853516,"speaker":"A"},{"text":"parameters","start":2615370,"end":2615890,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":2615890,"end":2616010,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2616010,"end":2616130,"confidence":0.9995117,"speaker":"A"},{"text":"take","start":2616130,"end":2616330,"confidence":1,"speaker":"A"},{"text":"to","start":2616330,"end":2616570,"confidence":0.97314453,"speaker":"A"},{"text":"to","start":2616570,"end":2616810,"confidence":0.9995117,"speaker":"A"},{"text":"filter","start":2616810,"end":2617170,"confidence":0.9663086,"speaker":"A"},{"text":"out,","start":2617170,"end":2617410,"confidence":1,"speaker":"A"},{"text":"like","start":2617410,"end":2617610,"confidence":0.99658203,"speaker":"A"},{"text":"which","start":2617610,"end":2617890,"confidence":0.99902344,"speaker":"A"},{"text":"RSS","start":2617890,"end":2618410,"confidence":0.99853516,"speaker":"A"},{"text":"feeds","start":2618410,"end":2618970,"confidence":0.9991862,"speaker":"A"},{"text":"are","start":2619290,"end":2619610,"confidence":0.96240234,"speaker":"A"},{"text":"high","start":2619610,"end":2619810,"confidence":1,"speaker":"A"},{"text":"priority","start":2619810,"end":2620170,"confidence":1,"speaker":"A"},{"text":"and","start":2620170,"end":2620330,"confidence":0.92626953,"speaker":"A"},{"text":"which","start":2620330,"end":2620450,"confidence":1,"speaker":"A"},{"text":"ones","start":2620450,"end":2620690,"confidence":0.9995117,"speaker":"A"},{"text":"aren't","start":2620690,"end":2621010,"confidence":0.99768066,"speaker":"A"},{"text":"based","start":2621010,"end":2621170,"confidence":1,"speaker":"A"},{"text":"on","start":2621170,"end":2621330,"confidence":1,"speaker":"A"},{"text":"the","start":2621330,"end":2621490,"confidence":0.99365234,"speaker":"A"},{"text":"audience","start":2621490,"end":2621770,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2621770,"end":2621970,"confidence":0.9975586,"speaker":"A"},{"text":"etc.","start":2621970,"end":2622650,"confidence":0.90723,"speaker":"A"},{"text":"So","start":2622650,"end":2623050,"confidence":0.9946289,"speaker":"A"},{"text":"yeah,","start":2623850,"end":2624330,"confidence":0.95377606,"speaker":"A"},{"text":"so","start":2624890,"end":2625170,"confidence":0.99853516,"speaker":"A"},{"text":"that's","start":2625170,"end":2625450,"confidence":0.9946289,"speaker":"A"},{"text":"deployment.","start":2625450,"end":2626170,"confidence":0.9991862,"speaker":"A"},{"text":"That's","start":2627050,"end":2627450,"confidence":0.9998372,"speaker":"A"},{"text":"how","start":2627450,"end":2627530,"confidence":1,"speaker":"A"},{"text":"you","start":2627530,"end":2627650,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2627650,"end":2627770,"confidence":1,"speaker":"A"},{"text":"get","start":2627770,"end":2627890,"confidence":1,"speaker":"A"},{"text":"that","start":2627890,"end":2628090,"confidence":1,"speaker":"A"},{"text":"working.","start":2628090,"end":2628410,"confidence":0.9995117,"speaker":"A"},{"text":"There's","start":2628810,"end":2629250,"confidence":0.9996745,"speaker":"A"},{"text":"weird","start":2629250,"end":2629490,"confidence":1,"speaker":"A"},{"text":"stuff","start":2629490,"end":2629690,"confidence":1,"speaker":"A"},{"text":"with","start":2629690,"end":2629850,"confidence":0.99609375,"speaker":"A"},{"text":"cloud","start":2629850,"end":2630290,"confidence":0.8815918,"speaker":"A"},{"text":"with","start":2630290,"end":2630650,"confidence":0.9873047,"speaker":"A"},{"text":"GitHub","start":2630810,"end":2631530,"confidence":0.99853516,"speaker":"A"},{"text":"that","start":2632730,"end":2633130,"confidence":0.9975586,"speaker":"A"},{"text":"I've","start":2633690,"end":2634010,"confidence":1,"speaker":"A"},{"text":"noticed.","start":2634010,"end":2634330,"confidence":0.99869794,"speaker":"A"},{"text":"If","start":2634330,"end":2634530,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":2634530,"end":2634730,"confidence":0.9995117,"speaker":"A"},{"text":"haven't","start":2634730,"end":2635010,"confidence":0.9984131,"speaker":"A"},{"text":"updated","start":2635010,"end":2635370,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2635370,"end":2635610,"confidence":0.96240234,"speaker":"A"},{"text":"in","start":2635610,"end":2635810,"confidence":0.99902344,"speaker":"A"},{"text":"a","start":2635810,"end":2635970,"confidence":0.99560547,"speaker":"A"},{"text":"while,","start":2635970,"end":2636250,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":2636250,"end":2636530,"confidence":1,"speaker":"A"},{"text":"doesn't","start":2636530,"end":2636770,"confidence":0.9998372,"speaker":"A"},{"text":"run","start":2636770,"end":2636970,"confidence":0.99853516,"speaker":"A"},{"text":"these","start":2636970,"end":2637210,"confidence":0.96777344,"speaker":"A"},{"text":"cron","start":2637210,"end":2637490,"confidence":0.90527344,"speaker":"A"},{"text":"jobs.","start":2637490,"end":2637770,"confidence":0.99072266,"speaker":"A"},{"text":"So","start":2637770,"end":2637850,"confidence":0.9951172,"speaker":"A"},{"text":"I","start":2637850,"end":2637930,"confidence":1,"speaker":"A"},{"text":"need","start":2637930,"end":2638050,"confidence":1,"speaker":"A"},{"text":"to","start":2638050,"end":2638170,"confidence":0.99902344,"speaker":"A"},{"text":"figure","start":2638170,"end":2638330,"confidence":0.99975586,"speaker":"A"},{"text":"out","start":2638330,"end":2638490,"confidence":0.98828125,"speaker":"A"},{"text":"a","start":2638490,"end":2638690,"confidence":0.89941406,"speaker":"A"},{"text":"how","start":2638690,"end":2638850,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":2638850,"end":2638970,"confidence":0.9995117,"speaker":"A"},{"text":"get","start":2638970,"end":2639050,"confidence":0.9995117,"speaker":"A"},{"text":"around","start":2639050,"end":2639210,"confidence":0.99853516,"speaker":"A"},{"text":"it","start":2639210,"end":2639410,"confidence":0.9238281,"speaker":"A"},{"text":"or","start":2639410,"end":2639570,"confidence":0.9995117,"speaker":"A"},{"text":"find","start":2639570,"end":2639730,"confidence":0.9995117,"speaker":"A"},{"text":"another","start":2639730,"end":2640010,"confidence":0.9477539,"speaker":"A"},{"text":"service","start":2640090,"end":2640450,"confidence":0.9819336,"speaker":"A"},{"text":"to","start":2640450,"end":2640650,"confidence":0.9970703,"speaker":"A"},{"text":"do","start":2640650,"end":2640730,"confidence":0.99902344,"speaker":"A"},{"text":"it.","start":2640730,"end":2640970,"confidence":0.9975586,"speaker":"A"},{"text":"This","start":2642830,"end":2642950,"confidence":0.9897461,"speaker":"A"},{"text":"is","start":2642950,"end":2643110,"confidence":0.9975586,"speaker":"A"},{"text":"all","start":2643110,"end":2643270,"confidence":0.9995117,"speaker":"A"},{"text":"free","start":2643270,"end":2643550,"confidence":1,"speaker":"A"},{"text":"because","start":2643630,"end":2644030,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":2644110,"end":2644590,"confidence":0.99934894,"speaker":"A"},{"text":"public","start":2644590,"end":2644870,"confidence":1,"speaker":"A"},{"text":"and","start":2644870,"end":2645230,"confidence":0.7548828,"speaker":"A"},{"text":"it","start":2646990,"end":2647310,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2647310,"end":2647550,"confidence":0.9995117,"speaker":"A"},{"text":"running","start":2647550,"end":2647870,"confidence":0.9987793,"speaker":"A"},{"text":"on","start":2647870,"end":2647990,"confidence":0.7963867,"speaker":"A"},{"text":"Ubuntu.","start":2647990,"end":2648590,"confidence":0.8631836,"speaker":"A"},{"text":"So","start":2648670,"end":2648910,"confidence":0.9980469,"speaker":"A"},{"text":"that's","start":2648910,"end":2649310,"confidence":0.99934894,"speaker":"A"},{"text":"really","start":2649310,"end":2649550,"confidence":1,"speaker":"A"},{"text":"great.","start":2649550,"end":2649870,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":2652350,"end":2652750,"confidence":0.9838867,"speaker":"A"},{"text":"the","start":2652830,"end":2653110,"confidence":0.9995117,"speaker":"A"},{"text":"storage","start":2653110,"end":2653430,"confidence":1,"speaker":"A"},{"text":"on","start":2653430,"end":2653590,"confidence":0.9951172,"speaker":"A"},{"text":"CloudKit","start":2653590,"end":2654150,"confidence":0.94189453,"speaker":"A"},{"text":"is","start":2654150,"end":2654310,"confidence":0.99902344,"speaker":"A"},{"text":"dirt","start":2654310,"end":2654590,"confidence":0.8517253,"speaker":"A"},{"text":"cheap,","start":2654590,"end":2654990,"confidence":0.8378906,"speaker":"A"},{"text":"which","start":2655390,"end":2655670,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2655670,"end":2655830,"confidence":1,"speaker":"A"},{"text":"even","start":2655830,"end":2656070,"confidence":1,"speaker":"A"},{"text":"more","start":2656070,"end":2656310,"confidence":1,"speaker":"A"},{"text":"awesome.","start":2656310,"end":2656830,"confidence":0.99886066,"speaker":"A"},{"text":"Sorry,","start":2660030,"end":2660590,"confidence":0.99593097,"speaker":"A"},{"text":"let's","start":2660990,"end":2661350,"confidence":0.89501953,"speaker":"A"},{"text":"see","start":2661350,"end":2661550,"confidence":0.9848633,"speaker":"A"},{"text":"what","start":2661550,"end":2661750,"confidence":0.99609375,"speaker":"A"},{"text":"else.","start":2661750,"end":2662110,"confidence":0.99975586,"speaker":"A"},{"text":"I","start":2663630,"end":2663870,"confidence":0.9682617,"speaker":"A"},{"text":"just","start":2663870,"end":2663990,"confidence":0.9824219,"speaker":"A"},{"text":"want","start":2663990,"end":2664110,"confidence":0.75878906,"speaker":"A"},{"text":"to","start":2664110,"end":2664230,"confidence":0.7807617,"speaker":"A"},{"text":"make","start":2664230,"end":2664350,"confidence":0.9995117,"speaker":"A"},{"text":"sure","start":2664350,"end":2664430,"confidence":1,"speaker":"A"},{"text":"I","start":2664430,"end":2664550,"confidence":0.98779297,"speaker":"A"},{"text":"covered","start":2664550,"end":2664870,"confidence":0.99975586,"speaker":"A"},{"text":"all","start":2664870,"end":2665070,"confidence":0.99902344,"speaker":"A"},{"text":"my","start":2665070,"end":2665390,"confidence":0.9970703,"speaker":"A"},{"text":"slides.","start":2665630,"end":2666150,"confidence":0.99975586,"speaker":"A"},{"text":"The","start":2666150,"end":2666390,"confidence":0.9995117,"speaker":"A"},{"text":"last","start":2666390,"end":2666590,"confidence":1,"speaker":"A"},{"text":"thing","start":2666590,"end":2666790,"confidence":1,"speaker":"A"},{"text":"I'm","start":2666790,"end":2666990,"confidence":0.9980469,"speaker":"A"},{"text":"going","start":2666990,"end":2667070,"confidence":0.96777344,"speaker":"A"},{"text":"to","start":2667070,"end":2667150,"confidence":0.9995117,"speaker":"A"},{"text":"talk","start":2667150,"end":2667270,"confidence":1,"speaker":"A"},{"text":"about","start":2667270,"end":2667470,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2667470,"end":2667670,"confidence":0.9941406,"speaker":"A"},{"text":"just","start":2667670,"end":2667830,"confidence":0.9941406,"speaker":"A"},{"text":"what","start":2667830,"end":2667990,"confidence":0.99853516,"speaker":"A"},{"text":"are","start":2667990,"end":2668150,"confidence":0.99902344,"speaker":"A"},{"text":"my","start":2668150,"end":2668310,"confidence":1,"speaker":"A"},{"text":"plans?","start":2668310,"end":2668670,"confidence":0.92578125,"speaker":"A"},{"text":"Excuse","start":2670390,"end":2670750,"confidence":0.9793294,"speaker":"A"},{"text":"me.","start":2670750,"end":2671030,"confidence":1,"speaker":"A"},{"text":"So","start":2671510,"end":2671790,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":2671790,"end":2671910,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":2671910,"end":2672070,"confidence":0.99934894,"speaker":"A"},{"text":"know","start":2672070,"end":2672150,"confidence":1,"speaker":"A"},{"text":"if","start":2672150,"end":2672230,"confidence":1,"speaker":"A"},{"text":"you","start":2672230,"end":2672390,"confidence":0.9995117,"speaker":"A"},{"text":"check.","start":2672390,"end":2672790,"confidence":0.7727051,"speaker":"A"},{"text":"Follow","start":2672790,"end":2673150,"confidence":0.9663086,"speaker":"A"},{"text":"me.","start":2673150,"end":2673390,"confidence":1,"speaker":"A"},{"text":"But","start":2673390,"end":2673550,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":2673550,"end":2673710,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":2673710,"end":2673910,"confidence":0.99902344,"speaker":"A"},{"text":"released.","start":2673910,"end":2674550,"confidence":0.99975586,"speaker":"A"},{"text":"I","start":2681910,"end":2682190,"confidence":0.98876953,"speaker":"A"},{"text":"just","start":2682190,"end":2682350,"confidence":1,"speaker":"A"},{"text":"released","start":2682350,"end":2682710,"confidence":0.99975586,"speaker":"A"},{"text":"Alpha","start":2682710,"end":2683150,"confidence":0.85091144,"speaker":"A"},{"text":"5","start":2683150,"end":2683430,"confidence":0.99414,"speaker":"A"},{"text":"that","start":2684310,"end":2684630,"confidence":1,"speaker":"A"},{"text":"has","start":2684630,"end":2684909,"confidence":0.9995117,"speaker":"A"},{"text":"lookup","start":2684909,"end":2685390,"confidence":0.89086914,"speaker":"A"},{"text":"zones,","start":2685390,"end":2685750,"confidence":0.9760742,"speaker":"A"},{"text":"fetch,","start":2685750,"end":2686150,"confidence":0.9900716,"speaker":"A"},{"text":"record","start":2686150,"end":2686430,"confidence":0.9995117,"speaker":"A"},{"text":"changes","start":2686430,"end":2686870,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2686870,"end":2687030,"confidence":0.6220703,"speaker":"A"},{"text":"upload","start":2687030,"end":2687430,"confidence":0.71809894,"speaker":"A"},{"text":"assets.","start":2687430,"end":2687990,"confidence":1,"speaker":"A"},{"text":"Upload","start":2688310,"end":2688750,"confidence":0.9840495,"speaker":"A"},{"text":"the","start":2688750,"end":2688910,"confidence":0.7114258,"speaker":"A"},{"text":"assets","start":2688910,"end":2689270,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":2689270,"end":2689470,"confidence":0.9814453,"speaker":"A"},{"text":"pretty","start":2689470,"end":2689710,"confidence":1,"speaker":"A"},{"text":"awesome.","start":2689710,"end":2690150,"confidence":1,"speaker":"A"},{"text":"When","start":2690230,"end":2690510,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2690510,"end":2690670,"confidence":1,"speaker":"A"},{"text":"saw","start":2690670,"end":2690830,"confidence":1,"speaker":"A"},{"text":"that","start":2690830,"end":2691030,"confidence":0.9995117,"speaker":"A"},{"text":"work","start":2691030,"end":2691310,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":2691310,"end":2691590,"confidence":1,"speaker":"A"},{"text":"I","start":2691590,"end":2691750,"confidence":0.9536133,"speaker":"A"},{"text":"was","start":2691750,"end":2691870,"confidence":0.9975586,"speaker":"A"},{"text":"like,","start":2691870,"end":2691990,"confidence":0.9980469,"speaker":"A"},{"text":"cool,","start":2691990,"end":2692190,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":2692190,"end":2692310,"confidence":0.9951172,"speaker":"A"},{"text":"can","start":2692310,"end":2692470,"confidence":0.9970703,"speaker":"A"},{"text":"actually","start":2692470,"end":2692670,"confidence":0.9995117,"speaker":"A"},{"text":"upload","start":2692670,"end":2693030,"confidence":1,"speaker":"A"},{"text":"a","start":2693030,"end":2693150,"confidence":0.9951172,"speaker":"A"},{"text":"binary","start":2693150,"end":2693750,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":2694630,"end":2694910,"confidence":0.96728516,"speaker":"A"},{"text":"CloudKit,","start":2694910,"end":2695510,"confidence":0.98046875,"speaker":"A"},{"text":"which","start":2695510,"end":2695710,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":2695710,"end":2695830,"confidence":0.9995117,"speaker":"A"},{"text":"awesome.","start":2695830,"end":2696230,"confidence":0.9998372,"speaker":"A"},{"text":"We","start":2697310,"end":2697430,"confidence":0.99121094,"speaker":"A"},{"text":"got","start":2697430,"end":2697630,"confidence":0.9946289,"speaker":"A"},{"text":"query","start":2697630,"end":2697990,"confidence":0.9836426,"speaker":"A"},{"text":"filters","start":2697990,"end":2698470,"confidence":0.9889323,"speaker":"A"},{"text":"to","start":2698470,"end":2698630,"confidence":0.99853516,"speaker":"A"},{"text":"work","start":2698630,"end":2698790,"confidence":1,"speaker":"A"},{"text":"for","start":2698790,"end":2698950,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":2698950,"end":2699150,"confidence":0.88183594,"speaker":"A"},{"text":"and","start":2699150,"end":2699310,"confidence":0.9741211,"speaker":"A"},{"text":"not","start":2699310,"end":2699510,"confidence":0.98339844,"speaker":"A"},{"text":"in,","start":2699510,"end":2699870,"confidence":0.8652344,"speaker":"A"},{"text":"so","start":2699870,"end":2700110,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":2700110,"end":2700190,"confidence":0.99853516,"speaker":"A"},{"text":"could","start":2700190,"end":2700350,"confidence":0.95410156,"speaker":"A"},{"text":"do","start":2700350,"end":2700550,"confidence":1,"speaker":"A"},{"text":"that","start":2700550,"end":2700830,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2701470,"end":2701790,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":2701790,"end":2702110,"confidence":0.9995117,"speaker":"A"},{"text":"plans","start":2702110,"end":2702630,"confidence":0.95043945,"speaker":"A"},{"text":"to","start":2702630,"end":2702750,"confidence":0.95166016,"speaker":"A"},{"text":"continue","start":2702750,"end":2702950,"confidence":0.9980469,"speaker":"A"},{"text":"working","start":2702950,"end":2703230,"confidence":0.9238281,"speaker":"A"},{"text":"on","start":2703230,"end":2703430,"confidence":0.99853516,"speaker":"A"},{"text":"this","start":2703430,"end":2703630,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":2703630,"end":2703830,"confidence":0.9555664,"speaker":"A"},{"text":"I","start":2703830,"end":2703990,"confidence":0.9995117,"speaker":"A"},{"text":"think","start":2703990,"end":2704230,"confidence":0.99902344,"speaker":"A"},{"text":"there's","start":2704230,"end":2704710,"confidence":0.9991862,"speaker":"A"},{"text":"a","start":2704710,"end":2704830,"confidence":0.9995117,"speaker":"A"},{"text":"big","start":2704830,"end":2704990,"confidence":0.99902344,"speaker":"A"},{"text":"future","start":2704990,"end":2705270,"confidence":0.9970703,"speaker":"A"},{"text":"for","start":2705270,"end":2705510,"confidence":0.9995117,"speaker":"A"},{"text":"something","start":2705510,"end":2705750,"confidence":0.99560547,"speaker":"A"},{"text":"like","start":2705750,"end":2705990,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2705990,"end":2706190,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":2706190,"end":2706390,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2706390,"end":2706510,"confidence":0.9995117,"speaker":"A"},{"text":"lot","start":2706510,"end":2706590,"confidence":1,"speaker":"A"},{"text":"of","start":2706590,"end":2706710,"confidence":0.9995117,"speaker":"A"},{"text":"people.","start":2706710,"end":2706990,"confidence":0.9995117,"speaker":"A"},{"text":"Yes,","start":2709150,"end":2709590,"confidence":0.9716797,"speaker":"A"},{"text":"you","start":2709590,"end":2709830,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2709830,"end":2709990,"confidence":0.93603516,"speaker":"A"},{"text":"technically","start":2709990,"end":2710350,"confidence":0.9992676,"speaker":"A"},{"text":"use","start":2710350,"end":2710590,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":2710590,"end":2710790,"confidence":0.98095703,"speaker":"A"},{"text":"in","start":2710790,"end":2710950,"confidence":0.9633789,"speaker":"A"},{"text":"Android","start":2710950,"end":2711470,"confidence":0.99934894,"speaker":"A"},{"text":"or","start":2711470,"end":2711710,"confidence":0.9995117,"speaker":"A"},{"text":"Windows","start":2711710,"end":2712270,"confidence":0.9972331,"speaker":"A"},{"text":"because","start":2712670,"end":2713070,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2713230,"end":2713510,"confidence":0.9970703,"speaker":"A"},{"text":"Swift","start":2713510,"end":2713950,"confidence":0.998291,"speaker":"A"},{"text":"thing","start":2714270,"end":2714590,"confidence":0.99902344,"speaker":"A"},{"text":"does","start":2714590,"end":2714830,"confidence":0.9995117,"speaker":"A"},{"text":"compile","start":2714830,"end":2715190,"confidence":0.99487305,"speaker":"A"},{"text":"in","start":2715190,"end":2715350,"confidence":0.78271484,"speaker":"A"},{"text":"Android","start":2715350,"end":2715750,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":2715750,"end":2715910,"confidence":0.72753906,"speaker":"A"},{"text":"Windows.","start":2715910,"end":2716230,"confidence":0.99934894,"speaker":"A"},{"text":"You","start":2716230,"end":2716350,"confidence":0.9970703,"speaker":"A"},{"text":"can","start":2716350,"end":2716430,"confidence":0.88623047,"speaker":"A"},{"text":"see","start":2716430,"end":2716550,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2716550,"end":2716670,"confidence":0.63378906,"speaker":"A"},{"text":"already","start":2716670,"end":2716830,"confidence":0.99560547,"speaker":"A"},{"text":"added","start":2716830,"end":2717110,"confidence":0.9819336,"speaker":"A"},{"text":"support","start":2717110,"end":2717430,"confidence":1,"speaker":"A"},{"text":"for","start":2717430,"end":2717670,"confidence":1,"speaker":"A"},{"text":"that.","start":2717670,"end":2717950,"confidence":0.9995117,"speaker":"A"},{"text":"This","start":2718430,"end":2718710,"confidence":0.99609375,"speaker":"A"},{"text":"is","start":2718710,"end":2718870,"confidence":0.9975586,"speaker":"A"},{"text":"the","start":2718870,"end":2719030,"confidence":0.88720703,"speaker":"A"},{"text":"support","start":2719030,"end":2719270,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":2719270,"end":2719510,"confidence":0.99658203,"speaker":"A"},{"text":"recently","start":2719510,"end":2719790,"confidence":1,"speaker":"A"},{"text":"had.","start":2719870,"end":2720270,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":2720750,"end":2721030,"confidence":0.9814453,"speaker":"A"},{"text":"then","start":2721030,"end":2721310,"confidence":0.99121094,"speaker":"A"},{"text":"we're.","start":2722120,"end":2722360,"confidence":0.77229816,"speaker":"A"},{"text":"I'm","start":2722360,"end":2722600,"confidence":0.9868164,"speaker":"A"},{"text":"just","start":2722600,"end":2722720,"confidence":0.9995117,"speaker":"A"},{"text":"kind","start":2722720,"end":2722840,"confidence":0.9946289,"speaker":"A"},{"text":"of","start":2722840,"end":2722960,"confidence":0.9370117,"speaker":"A"},{"text":"like","start":2722960,"end":2723200,"confidence":0.99609375,"speaker":"A"},{"text":"going","start":2723200,"end":2723480,"confidence":0.99902344,"speaker":"A"},{"text":"through","start":2723480,"end":2723720,"confidence":1,"speaker":"A"},{"text":"each","start":2723720,"end":2723920,"confidence":0.9995117,"speaker":"A"},{"text":"of","start":2723920,"end":2724040,"confidence":0.9995117,"speaker":"A"},{"text":"these","start":2724040,"end":2724280,"confidence":0.99902344,"speaker":"A"},{"text":"because","start":2724280,"end":2724680,"confidence":0.7866211,"speaker":"A"},{"text":"as","start":2724680,"end":2725000,"confidence":1,"speaker":"A"},{"text":"great","start":2725000,"end":2725240,"confidence":0.9951172,"speaker":"A"},{"text":"as","start":2725240,"end":2725480,"confidence":0.9946289,"speaker":"A"},{"text":"AI","start":2725480,"end":2725880,"confidence":0.8781738,"speaker":"A"},{"text":"is,","start":2725880,"end":2726160,"confidence":0.9946289,"speaker":"A"},{"text":"it's","start":2726160,"end":2726440,"confidence":0.9995117,"speaker":"A"},{"text":"not","start":2726440,"end":2726600,"confidence":0.9995117,"speaker":"A"},{"text":"perfect.","start":2726600,"end":2727000,"confidence":0.9840495,"speaker":"A"},{"text":"So","start":2727080,"end":2727480,"confidence":0.99853516,"speaker":"A"},{"text":"we're","start":2728040,"end":2728360,"confidence":0.99934894,"speaker":"A"},{"text":"just","start":2728360,"end":2728440,"confidence":1,"speaker":"A"},{"text":"kind","start":2728440,"end":2728560,"confidence":0.99365234,"speaker":"A"},{"text":"of","start":2728560,"end":2728680,"confidence":0.98828125,"speaker":"A"},{"text":"going","start":2728680,"end":2728880,"confidence":0.99365234,"speaker":"A"},{"text":"through","start":2728880,"end":2729120,"confidence":1,"speaker":"A"},{"text":"these","start":2729120,"end":2729400,"confidence":0.98779297,"speaker":"A"},{"text":"piece","start":2729720,"end":2730120,"confidence":0.9848633,"speaker":"A"},{"text":"by","start":2730120,"end":2730360,"confidence":0.99902344,"speaker":"A"},{"text":"piece","start":2730360,"end":2730760,"confidence":0.9983724,"speaker":"A"},{"text":"with","start":2730840,"end":2731120,"confidence":0.9995117,"speaker":"A"},{"text":"each","start":2731120,"end":2731400,"confidence":0.9995117,"speaker":"A"},{"text":"version","start":2731640,"end":2732080,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":2732080,"end":2732240,"confidence":0.5917969,"speaker":"A"},{"text":"hammering","start":2732240,"end":2732560,"confidence":0.9977214,"speaker":"A"},{"text":"these","start":2732560,"end":2732760,"confidence":0.99609375,"speaker":"A"},{"text":"away","start":2732760,"end":2733080,"confidence":0.9980469,"speaker":"A"},{"text":"and","start":2735400,"end":2735720,"confidence":0.9951172,"speaker":"A"},{"text":"then","start":2735720,"end":2736040,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":2736680,"end":2736960,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":2736960,"end":2737120,"confidence":0.99365234,"speaker":"A"},{"text":"actually","start":2737120,"end":2737360,"confidence":0.9995117,"speaker":"A"},{"text":"done.","start":2737360,"end":2737640,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":2737640,"end":2737840,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":2737840,"end":2738000,"confidence":0.98844403,"speaker":"A"},{"text":"even","start":2738000,"end":2738159,"confidence":0.99902344,"speaker":"A"},{"text":"know","start":2738159,"end":2738279,"confidence":1,"speaker":"A"},{"text":"why","start":2738279,"end":2738400,"confidence":0.99902344,"speaker":"A"},{"text":"that's","start":2738400,"end":2738680,"confidence":0.9995117,"speaker":"A"},{"text":"there.","start":2738680,"end":2738880,"confidence":0.99853516,"speaker":"A"},{"text":"But","start":2738880,"end":2739240,"confidence":0.99658203,"speaker":"A"},{"text":"yeah,","start":2739640,"end":2740160,"confidence":0.99934894,"speaker":"A"},{"text":"I","start":2740160,"end":2740400,"confidence":0.83203125,"speaker":"A"},{"text":"think","start":2740400,"end":2740680,"confidence":0.92529297,"speaker":"A"},{"text":"system","start":2740680,"end":2741080,"confidence":0.9995117,"speaker":"A"},{"text":"field","start":2741080,"end":2741480,"confidence":0.9916992,"speaker":"A"},{"text":"integration","start":2741640,"end":2742280,"confidence":0.93859863,"speaker":"A"},{"text":"might","start":2742280,"end":2742480,"confidence":0.9980469,"speaker":"A"},{"text":"already","start":2742480,"end":2742720,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2742720,"end":2742960,"confidence":1,"speaker":"A"},{"text":"there","start":2742960,"end":2743240,"confidence":1,"speaker":"A"},{"text":"and","start":2743400,"end":2743680,"confidence":0.9980469,"speaker":"A"},{"text":"there's","start":2743680,"end":2743960,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":2743960,"end":2744040,"confidence":0.9995117,"speaker":"A"},{"text":"few","start":2744040,"end":2744160,"confidence":0.9995117,"speaker":"A"},{"text":"other","start":2744160,"end":2744400,"confidence":1,"speaker":"A"},{"text":"things.","start":2744400,"end":2744760,"confidence":0.9995117,"speaker":"A"},{"text":"Eventually","start":2745960,"end":2746520,"confidence":0.9992676,"speaker":"A"},{"text":"I'd","start":2746520,"end":2746800,"confidence":0.92122394,"speaker":"A"},{"text":"like","start":2746800,"end":2746960,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2746960,"end":2747160,"confidence":0.99902344,"speaker":"A"},{"text":"add","start":2747160,"end":2747480,"confidence":0.9975586,"speaker":"A"},{"text":"support.","start":2747880,"end":2748120,"confidence":0.9902344,"speaker":"A"},{"text":"So","start":2748200,"end":2748480,"confidence":0.99902344,"speaker":"A"},{"text":"there,","start":2748480,"end":2748720,"confidence":0.38134766,"speaker":"A"},{"text":"there's","start":2748720,"end":2749080,"confidence":0.9998372,"speaker":"A"},{"text":"a","start":2749080,"end":2749200,"confidence":0.9995117,"speaker":"A"},{"text":"whole","start":2749200,"end":2749440,"confidence":0.99975586,"speaker":"A"},{"text":"API","start":2749440,"end":2749880,"confidence":0.9975586,"speaker":"A"},{"text":"for","start":2749880,"end":2750120,"confidence":0.9975586,"speaker":"A"},{"text":"CloudKit","start":2750120,"end":2750760,"confidence":0.99609375,"speaker":"A"},{"text":"schema","start":2750760,"end":2751200,"confidence":0.8933919,"speaker":"A"},{"text":"management","start":2751200,"end":2751480,"confidence":0.99121094,"speaker":"A"},{"text":"that","start":2752600,"end":2752880,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2752880,"end":2753000,"confidence":0.9658203,"speaker":"A"},{"text":"could.","start":2753000,"end":2753200,"confidence":0.8144531,"speaker":"A"},{"text":"That","start":2753200,"end":2753440,"confidence":0.99902344,"speaker":"A"},{"text":"would","start":2753440,"end":2753560,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2753560,"end":2753680,"confidence":0.9995117,"speaker":"A"},{"text":"awesome","start":2753680,"end":2754080,"confidence":0.9998372,"speaker":"A"},{"text":"if","start":2754080,"end":2754320,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2754320,"end":2754440,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":2754440,"end":2754640,"confidence":0.9863281,"speaker":"A"},{"text":"figure","start":2754640,"end":2754920,"confidence":1,"speaker":"A"},{"text":"out","start":2754920,"end":2755040,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":2755040,"end":2755200,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2755200,"end":2755320,"confidence":1,"speaker":"A"},{"text":"do","start":2755320,"end":2755440,"confidence":0.9995117,"speaker":"A"},{"text":"that.","start":2755440,"end":2755720,"confidence":0.9995117,"speaker":"A"},{"text":"If","start":2755720,"end":2756000,"confidence":0.9995117,"speaker":"A"},{"text":"I","start":2756000,"end":2756120,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":2756120,"end":2756240,"confidence":0.84375,"speaker":"A"},{"text":"figure","start":2756240,"end":2756440,"confidence":1,"speaker":"A"},{"text":"out","start":2756440,"end":2756520,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":2756520,"end":2756600,"confidence":0.99853516,"speaker":"A"},{"text":"to","start":2756600,"end":2756680,"confidence":0.9975586,"speaker":"A"},{"text":"do","start":2756680,"end":2756800,"confidence":0.9921875,"speaker":"A"},{"text":"key","start":2756800,"end":2756960,"confidence":0.9682617,"speaker":"A"},{"text":"path","start":2756960,"end":2757280,"confidence":0.953125,"speaker":"A"},{"text":"query","start":2757280,"end":2757600,"confidence":0.9951172,"speaker":"A"},{"text":"filtering,","start":2757600,"end":2758120,"confidence":0.99934894,"speaker":"A"},{"text":"that","start":2758120,"end":2758320,"confidence":0.99902344,"speaker":"A"},{"text":"would","start":2758320,"end":2758480,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2758480,"end":2758640,"confidence":0.9995117,"speaker":"A"},{"text":"fantastic.","start":2758640,"end":2759400,"confidence":0.99890137,"speaker":"A"},{"text":"And","start":2761720,"end":2762120,"confidence":0.9951172,"speaker":"A"},{"text":"yeah,","start":2762280,"end":2762760,"confidence":0.9998372,"speaker":"A"},{"text":"but","start":2762760,"end":2762960,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":2762960,"end":2763200,"confidence":0.87320966,"speaker":"A"},{"text":"a.","start":2763200,"end":2763400,"confidence":0.92626953,"speaker":"A"},{"text":"I","start":2763400,"end":2763560,"confidence":0.9980469,"speaker":"A"},{"text":"mean","start":2763560,"end":2763799,"confidence":0.79785156,"speaker":"A"},{"text":"the","start":2763799,"end":2764120,"confidence":0.9995117,"speaker":"A"},{"text":"basics","start":2764120,"end":2764520,"confidence":0.998291,"speaker":"A"},{"text":"is","start":2764520,"end":2764760,"confidence":0.9941406,"speaker":"A"},{"text":"there","start":2764760,"end":2765040,"confidence":0.9995117,"speaker":"A"},{"text":"as","start":2765040,"end":2765280,"confidence":0.9995117,"speaker":"A"},{"text":"far","start":2765280,"end":2765440,"confidence":1,"speaker":"A"},{"text":"as","start":2765440,"end":2765640,"confidence":0.9995117,"speaker":"A"},{"text":"if","start":2765640,"end":2765840,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2765840,"end":2765960,"confidence":0.99902344,"speaker":"A"},{"text":"want","start":2765960,"end":2766080,"confidence":0.77685547,"speaker":"A"},{"text":"to","start":2766080,"end":2766240,"confidence":0.9946289,"speaker":"A"},{"text":"do","start":2766240,"end":2766400,"confidence":1,"speaker":"A"},{"text":"anything","start":2766400,"end":2766760,"confidence":0.99975586,"speaker":"A"},{"text":"with","start":2766760,"end":2766960,"confidence":1,"speaker":"A"},{"text":"a","start":2766960,"end":2767120,"confidence":0.99560547,"speaker":"A"},{"text":"record,","start":2767120,"end":2767400,"confidence":0.99902344,"speaker":"A"},{"text":"it's","start":2768040,"end":2768400,"confidence":0.9983724,"speaker":"A"},{"text":"pretty","start":2768400,"end":2768600,"confidence":0.9998372,"speaker":"A"},{"text":"much","start":2768600,"end":2768760,"confidence":0.99853516,"speaker":"A"},{"text":"there.","start":2768760,"end":2769080,"confidence":0.98583984,"speaker":"A"},{"text":"One","start":2769720,"end":2770000,"confidence":0.9848633,"speaker":"A"},{"text":"thing","start":2770000,"end":2770160,"confidence":0.99853516,"speaker":"A"},{"text":"with","start":2770160,"end":2770320,"confidence":0.9995117,"speaker":"A"},{"text":"Celestra","start":2770320,"end":2770880,"confidence":0.7967122,"speaker":"A"},{"text":"is","start":2770880,"end":2771040,"confidence":0.8798828,"speaker":"A"},{"text":"I'd","start":2771040,"end":2771240,"confidence":0.9977214,"speaker":"A"},{"text":"love","start":2771240,"end":2771400,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2771400,"end":2771560,"confidence":0.9995117,"speaker":"A"},{"text":"be","start":2771560,"end":2771720,"confidence":0.99902344,"speaker":"A"},{"text":"able","start":2771720,"end":2771920,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2771920,"end":2772080,"confidence":1,"speaker":"A"},{"text":"do","start":2772080,"end":2772280,"confidence":1,"speaker":"A"},{"text":"like","start":2772280,"end":2772560,"confidence":0.99902344,"speaker":"A"},{"text":"test","start":2772560,"end":2772880,"confidence":0.99853516,"speaker":"A"},{"text":"out","start":2772880,"end":2773160,"confidence":0.9970703,"speaker":"A"},{"text":"subscriptions","start":2773160,"end":2773880,"confidence":0.9428711,"speaker":"A"},{"text":"and","start":2774200,"end":2774320,"confidence":0.94921875,"speaker":"A"},{"text":"see","start":2774320,"end":2774480,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":2774480,"end":2774640,"confidence":1,"speaker":"A"},{"text":"that","start":2774640,"end":2774800,"confidence":1,"speaker":"A"},{"text":"works.","start":2774800,"end":2775240,"confidence":1,"speaker":"A"},{"text":"So","start":2775880,"end":2776280,"confidence":0.99609375,"speaker":"A"},{"text":"yeah,","start":2777320,"end":2777840,"confidence":0.9996745,"speaker":"A"},{"text":"that's","start":2777840,"end":2778200,"confidence":1,"speaker":"A"},{"text":"really","start":2778200,"end":2778360,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2778360,"end":2778560,"confidence":1,"speaker":"A"},{"text":"bulk","start":2778560,"end":2778800,"confidence":0.9817708,"speaker":"A"},{"text":"of","start":2778800,"end":2778960,"confidence":0.9995117,"speaker":"A"},{"text":"my","start":2778960,"end":2779120,"confidence":0.9995117,"speaker":"A"},{"text":"presentation","start":2779120,"end":2779720,"confidence":0.9995117,"speaker":"A"},{"text":"today.","start":2779720,"end":2780040,"confidence":0.99902344,"speaker":"A"},{"text":"Now","start":2781800,"end":2782160,"confidence":0.95751953,"speaker":"A"},{"text":"is.","start":2782160,"end":2782480,"confidence":0.8334961,"speaker":"A"},{"text":"Now","start":2782480,"end":2782720,"confidence":0.99902344,"speaker":"A"},{"text":"it's","start":2782720,"end":2782920,"confidence":0.99869794,"speaker":"A"},{"text":"time","start":2782920,"end":2783040,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2783040,"end":2783160,"confidence":0.9995117,"speaker":"A"},{"text":"ask","start":2783160,"end":2783280,"confidence":0.99902344,"speaker":"A"},{"text":"me","start":2783280,"end":2783440,"confidence":0.99658203,"speaker":"A"},{"text":"a","start":2783440,"end":2783560,"confidence":0.99902344,"speaker":"A"},{"text":"ton","start":2783560,"end":2783720,"confidence":0.9992676,"speaker":"A"},{"text":"of","start":2783720,"end":2783840,"confidence":0.9995117,"speaker":"A"},{"text":"questions","start":2783840,"end":2784200,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":2784200,"end":2784480,"confidence":0.9814453,"speaker":"A"},{"text":"make","start":2784480,"end":2784720,"confidence":0.9995117,"speaker":"A"},{"text":"me","start":2784720,"end":2784880,"confidence":0.9995117,"speaker":"A"},{"text":"feel","start":2784880,"end":2785040,"confidence":1,"speaker":"A"},{"text":"dumb.","start":2785040,"end":2785480,"confidence":0.98706055,"speaker":"A"},{"text":"Go","start":2785880,"end":2786160,"confidence":0.99121094,"speaker":"A"},{"text":"for","start":2786160,"end":2786320,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":2786320,"end":2786600,"confidence":0.99853516,"speaker":"A"},{"text":"No,","start":2788440,"end":2788840,"confidence":0.95751953,"speaker":"B"},{"text":"there's","start":2789880,"end":2790319,"confidence":0.9355469,"speaker":"B"},{"text":"a","start":2790319,"end":2790440,"confidence":0.9995117,"speaker":"B"},{"text":"lot","start":2790440,"end":2790600,"confidence":0.9995117,"speaker":"B"},{"text":"there","start":2790600,"end":2790840,"confidence":0.99902344,"speaker":"B"},{"text":"to.","start":2790840,"end":2791160,"confidence":0.98828125,"speaker":"B"},{"text":"To","start":2791400,"end":2791720,"confidence":0.99902344,"speaker":"B"},{"text":"absorb.","start":2791720,"end":2792160,"confidence":0.99938965,"speaker":"B"},{"text":"But","start":2792160,"end":2792320,"confidence":0.9995117,"speaker":"B"},{"text":"I,","start":2792320,"end":2792600,"confidence":0.99121094,"speaker":"B"},{"text":"I","start":2792760,"end":2793120,"confidence":0.99658203,"speaker":"B"},{"text":"like","start":2793120,"end":2793400,"confidence":0.99902344,"speaker":"B"},{"text":"the","start":2793400,"end":2793640,"confidence":0.9995117,"speaker":"B"},{"text":"concept","start":2793640,"end":2794200,"confidence":0.976888,"speaker":"B"},{"text":"and","start":2794440,"end":2794720,"confidence":0.99560547,"speaker":"B"},{"text":"I","start":2794720,"end":2794840,"confidence":0.9995117,"speaker":"B"},{"text":"know","start":2794840,"end":2794960,"confidence":1,"speaker":"B"},{"text":"you've","start":2794960,"end":2795280,"confidence":0.99820966,"speaker":"B"},{"text":"been","start":2795280,"end":2795440,"confidence":0.9995117,"speaker":"B"},{"text":"working","start":2795440,"end":2795640,"confidence":0.9995117,"speaker":"B"},{"text":"on","start":2795640,"end":2795840,"confidence":0.9995117,"speaker":"B"},{"text":"this","start":2795840,"end":2796000,"confidence":0.9995117,"speaker":"B"},{"text":"for","start":2796000,"end":2796120,"confidence":0.9995117,"speaker":"B"},{"text":"a","start":2796120,"end":2796240,"confidence":0.99560547,"speaker":"B"},{"text":"while","start":2796240,"end":2796400,"confidence":1,"speaker":"B"},{"text":"and","start":2796400,"end":2796560,"confidence":0.9458008,"speaker":"B"},{"text":"I","start":2796560,"end":2796680,"confidence":0.9975586,"speaker":"B"},{"text":"always","start":2796680,"end":2796840,"confidence":0.99316406,"speaker":"B"},{"text":"thought","start":2796840,"end":2797040,"confidence":0.99853516,"speaker":"B"},{"text":"it","start":2797040,"end":2797160,"confidence":0.9970703,"speaker":"B"},{"text":"was","start":2797160,"end":2797280,"confidence":0.9951172,"speaker":"B"},{"text":"a","start":2797280,"end":2797440,"confidence":0.9663086,"speaker":"B"},{"text":"pretty","start":2797440,"end":2797640,"confidence":0.99869794,"speaker":"B"},{"text":"cool,","start":2797640,"end":2797960,"confidence":0.9980469,"speaker":"B"},{"text":"pretty","start":2799240,"end":2799560,"confidence":0.9943034,"speaker":"B"},{"text":"cool","start":2799560,"end":2799720,"confidence":0.88549805,"speaker":"B"},{"text":"idea","start":2800030,"end":2800350,"confidence":0.72094727,"speaker":"B"},{"text":"and","start":2800590,"end":2800910,"confidence":0.89404297,"speaker":"B"},{"text":"implementation","start":2800910,"end":2801630,"confidence":0.9941406,"speaker":"B"},{"text":"of","start":2801630,"end":2801910,"confidence":0.9770508,"speaker":"B"},{"text":"this.","start":2801910,"end":2802190,"confidence":0.9897461,"speaker":"B"},{"text":"Questions?","start":2802750,"end":2803470,"confidence":0.9904785,"speaker":"A"},{"text":"So","start":2808990,"end":2809270,"confidence":0.95214844,"speaker":"C"},{"text":"with","start":2809270,"end":2809470,"confidence":0.9628906,"speaker":"C"},{"text":"something","start":2809470,"end":2809710,"confidence":0.9995117,"speaker":"C"},{"text":"like.","start":2809710,"end":2810030,"confidence":0.99853516,"speaker":"C"},{"text":"Accessing","start":2814110,"end":2814750,"confidence":0.78027344,"speaker":"C"},{"text":"CloudKit","start":2814830,"end":2815430,"confidence":0.94202,"speaker":"C"},{"text":"through","start":2815430,"end":2815550,"confidence":0.9946289,"speaker":"C"},{"text":"the","start":2815550,"end":2815709,"confidence":0.99902344,"speaker":"C"},{"text":"web,","start":2815709,"end":2816109,"confidence":0.9916992,"speaker":"C"},{"text":"is","start":2816430,"end":2816830,"confidence":0.9995117,"speaker":"C"},{"text":"this","start":2817150,"end":2817510,"confidence":0.99853516,"speaker":"C"},{"text":"setup","start":2817510,"end":2817910,"confidence":0.95092773,"speaker":"C"},{"text":"more","start":2817910,"end":2818110,"confidence":0.9995117,"speaker":"C"},{"text":"ideal","start":2818110,"end":2818590,"confidence":0.9970703,"speaker":"C"},{"text":"for","start":2818670,"end":2819070,"confidence":0.9995117,"speaker":"C"},{"text":"having","start":2820270,"end":2820630,"confidence":0.9995117,"speaker":"C"},{"text":"your","start":2820630,"end":2820990,"confidence":1,"speaker":"C"},{"text":"server","start":2820990,"end":2821630,"confidence":1,"speaker":"C"},{"text":"do","start":2821870,"end":2822270,"confidence":0.9995117,"speaker":"C"},{"text":"the","start":2822670,"end":2822990,"confidence":0.9980469,"speaker":"C"},{"text":"authentication","start":2822990,"end":2823710,"confidence":1,"speaker":"C"},{"text":"to","start":2823950,"end":2824230,"confidence":0.9970703,"speaker":"C"},{"text":"CloudKit","start":2824230,"end":2824790,"confidence":0.9939,"speaker":"C"},{"text":"with","start":2824790,"end":2824950,"confidence":0.99560547,"speaker":"C"},{"text":"Miskit","start":2824950,"end":2825550,"confidence":0.9923096,"speaker":"C"},{"text":"or","start":2825970,"end":2826210,"confidence":0.9921875,"speaker":"C"},{"text":"is","start":2826290,"end":2826650,"confidence":0.9980469,"speaker":"C"},{"text":"miskit","start":2826650,"end":2827250,"confidence":0.93859863,"speaker":"C"},{"text":"something","start":2827250,"end":2827490,"confidence":0.99853516,"speaker":"C"},{"text":"that","start":2827490,"end":2827650,"confidence":0.99658203,"speaker":"C"},{"text":"you","start":2827650,"end":2827770,"confidence":0.9995117,"speaker":"C"},{"text":"could","start":2827770,"end":2827970,"confidence":0.9970703,"speaker":"C"},{"text":"put","start":2827970,"end":2828210,"confidence":0.9995117,"speaker":"C"},{"text":"into","start":2828210,"end":2828530,"confidence":0.99902344,"speaker":"C"},{"text":"even","start":2828530,"end":2828850,"confidence":0.99560547,"speaker":"C"},{"text":"like","start":2828850,"end":2829050,"confidence":0.9765625,"speaker":"C"},{"text":"a","start":2829050,"end":2829330,"confidence":0.5620117,"speaker":"C"},{"text":"client","start":2829330,"end":2829890,"confidence":0.9987793,"speaker":"C"},{"text":"side,","start":2830130,"end":2830530,"confidence":0.52978516,"speaker":"C"},{"text":"you","start":2832850,"end":2833170,"confidence":0.95751953,"speaker":"C"},{"text":"know,","start":2833170,"end":2833370,"confidence":0.9995117,"speaker":"C"},{"text":"like","start":2833370,"end":2833650,"confidence":0.98583984,"speaker":"C"},{"text":"non","start":2834690,"end":2835090,"confidence":0.99658203,"speaker":"C"},{"text":"Swift","start":2835810,"end":2836290,"confidence":0.99780273,"speaker":"C"},{"text":"application","start":2836290,"end":2836770,"confidence":0.9995117,"speaker":"C"},{"text":"or","start":2836770,"end":2837010,"confidence":0.9140625,"speaker":"C"},{"text":"I","start":2837010,"end":2837210,"confidence":0.6401367,"speaker":"C"},{"text":"guess","start":2837210,"end":2837490,"confidence":0.99975586,"speaker":"C"},{"text":"not","start":2837490,"end":2837730,"confidence":0.9628906,"speaker":"C"},{"text":"non","start":2837730,"end":2837930,"confidence":0.8105469,"speaker":"C"},{"text":"Swift","start":2837930,"end":2838250,"confidence":0.9489746,"speaker":"C"},{"text":"but","start":2838250,"end":2838410,"confidence":0.98876953,"speaker":"C"},{"text":"like","start":2838410,"end":2838610,"confidence":0.98583984,"speaker":"C"},{"text":"non","start":2838610,"end":2838930,"confidence":0.9560547,"speaker":"C"},{"text":"like","start":2839090,"end":2839410,"confidence":0.79785156,"speaker":"C"},{"text":"app","start":2839410,"end":2839690,"confidence":0.99609375,"speaker":"C"},{"text":"application.","start":2839690,"end":2840170,"confidence":0.99853516,"speaker":"C"},{"text":"I'm","start":2840170,"end":2840410,"confidence":0.99397784,"speaker":"C"},{"text":"thinking","start":2840410,"end":2840730,"confidence":0.8215332,"speaker":"C"},{"text":"in","start":2840730,"end":2840970,"confidence":0.6489258,"speaker":"C"},{"text":"the","start":2840970,"end":2841130,"confidence":0.9946289,"speaker":"C"},{"text":"context","start":2841130,"end":2841450,"confidence":0.98502606,"speaker":"C"},{"text":"of","start":2841450,"end":2841570,"confidence":0.99902344,"speaker":"C"},{"text":"like","start":2841570,"end":2841730,"confidence":0.98876953,"speaker":"C"},{"text":"a.","start":2841730,"end":2842049,"confidence":0.71728516,"speaker":"A"},{"text":"I","start":2845730,"end":2845970,"confidence":0.99658203,"speaker":"C"},{"text":"guess","start":2845970,"end":2846170,"confidence":1,"speaker":"C"},{"text":"if","start":2846170,"end":2846290,"confidence":0.9970703,"speaker":"C"},{"text":"I","start":2846290,"end":2846410,"confidence":0.9995117,"speaker":"C"},{"text":"wanted","start":2846410,"end":2846730,"confidence":0.9848633,"speaker":"C"},{"text":"to","start":2846730,"end":2846930,"confidence":1,"speaker":"C"},{"text":"create","start":2846930,"end":2847250,"confidence":0.9995117,"speaker":"C"},{"text":"a","start":2847330,"end":2847730,"confidence":0.87939453,"speaker":"C"},{"text":"something","start":2849970,"end":2850290,"confidence":0.9970703,"speaker":"C"},{"text":"accessing","start":2850290,"end":2850810,"confidence":0.96655273,"speaker":"C"},{"text":"CloudKit","start":2850810,"end":2851330,"confidence":0.99853516,"speaker":"C"},{"text":"that","start":2851330,"end":2851490,"confidence":0.9995117,"speaker":"C"},{"text":"is","start":2851490,"end":2851610,"confidence":0.99902344,"speaker":"C"},{"text":"not","start":2851610,"end":2851810,"confidence":0.9995117,"speaker":"C"},{"text":"your","start":2851810,"end":2852010,"confidence":0.9995117,"speaker":"C"},{"text":"typical","start":2852010,"end":2852370,"confidence":1,"speaker":"C"},{"text":"Mac","start":2852370,"end":2852610,"confidence":0.99780273,"speaker":"C"},{"text":"or","start":2852610,"end":2852730,"confidence":0.9863281,"speaker":"C"},{"text":"iOS","start":2852730,"end":2853090,"confidence":0.9980469,"speaker":"C"},{"text":"app.","start":2853090,"end":2853410,"confidence":0.99853516,"speaker":"C"},{"text":"Can","start":2854880,"end":2855000,"confidence":0.9609375,"speaker":"A"},{"text":"you","start":2855000,"end":2855160,"confidence":0.8486328,"speaker":"A"},{"text":"be","start":2855160,"end":2855400,"confidence":0.9951172,"speaker":"A"},{"text":"more","start":2855400,"end":2855680,"confidence":1,"speaker":"A"},{"text":"specific?","start":2855680,"end":2856160,"confidence":0.99975586,"speaker":"A"},{"text":"I'm","start":2857840,"end":2858200,"confidence":0.99104816,"speaker":"C"},{"text":"looking","start":2858200,"end":2858480,"confidence":0.99902344,"speaker":"C"},{"text":"into","start":2858720,"end":2859120,"confidence":0.99560547,"speaker":"C"},{"text":"one.","start":2859280,"end":2859640,"confidence":0.45483398,"speaker":"C"},{"text":"One","start":2859640,"end":2859880,"confidence":1,"speaker":"C"},{"text":"approach","start":2859880,"end":2860120,"confidence":1,"speaker":"C"},{"text":"would","start":2860120,"end":2860400,"confidence":0.99560547,"speaker":"C"},{"text":"be","start":2860400,"end":2860720,"confidence":0.99853516,"speaker":"C"},{"text":"browser","start":2861600,"end":2862040,"confidence":0.9998372,"speaker":"C"},{"text":"extensions.","start":2862040,"end":2862560,"confidence":0.99869794,"speaker":"C"},{"text":"So","start":2865040,"end":2865440,"confidence":0.67871094,"speaker":"A"},{"text":"for","start":2865680,"end":2866000,"confidence":0.9926758,"speaker":"A"},{"text":"like","start":2866000,"end":2866200,"confidence":0.9321289,"speaker":"A"},{"text":"a","start":2866200,"end":2866320,"confidence":0.99121094,"speaker":"A"},{"text":"non","start":2866320,"end":2866520,"confidence":0.99560547,"speaker":"A"},{"text":"Safari","start":2866520,"end":2867080,"confidence":0.9980469,"speaker":"A"},{"text":"browser.","start":2867080,"end":2867680,"confidence":0.99609375,"speaker":"A"},{"text":"Yes.","start":2867760,"end":2868240,"confidence":0.99121094,"speaker":"C"},{"text":"Yeah,","start":2870400,"end":2870720,"confidence":0.9814453,"speaker":"A"},{"text":"this","start":2870720,"end":2870840,"confidence":0.9975586,"speaker":"A"},{"text":"would","start":2870840,"end":2871000,"confidence":0.9975586,"speaker":"A"},{"text":"be","start":2871000,"end":2871160,"confidence":0.9995117,"speaker":"A"},{"text":"great.","start":2871160,"end":2871400,"confidence":1,"speaker":"A"},{"text":"So","start":2871400,"end":2871600,"confidence":0.96240234,"speaker":"A"},{"text":"basically","start":2871600,"end":2872000,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":2873040,"end":2873320,"confidence":0.9995117,"speaker":"A"},{"text":"way","start":2873320,"end":2873560,"confidence":0.9995117,"speaker":"A"},{"text":"you'd","start":2873560,"end":2873960,"confidence":0.98860675,"speaker":"A"},{"text":"want","start":2873960,"end":2874120,"confidence":1,"speaker":"A"},{"text":"that","start":2874120,"end":2874320,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2874320,"end":2874560,"confidence":0.99853516,"speaker":"A"},{"text":"work,","start":2874560,"end":2874880,"confidence":0.99902344,"speaker":"A"},{"text":"like","start":2875040,"end":2875400,"confidence":0.73095703,"speaker":"A"},{"text":"the","start":2875400,"end":2875640,"confidence":0.9980469,"speaker":"A"},{"text":"sticky","start":2875640,"end":2876040,"confidence":0.9973958,"speaker":"A"},{"text":"part","start":2876040,"end":2876200,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":2876200,"end":2876360,"confidence":0.9980469,"speaker":"A"},{"text":"me","start":2876360,"end":2876560,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":2876560,"end":2876760,"confidence":0.9980469,"speaker":"A"},{"text":"be","start":2876760,"end":2876920,"confidence":0.9995117,"speaker":"A"},{"text":"getting","start":2876920,"end":2877120,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":2877120,"end":2877320,"confidence":0.99902344,"speaker":"A"},{"text":"web","start":2877320,"end":2877560,"confidence":0.998291,"speaker":"A"},{"text":"authentication","start":2877560,"end":2878240,"confidence":0.92614746,"speaker":"A"},{"text":"token.","start":2878240,"end":2878640,"confidence":0.99934894,"speaker":"A"},{"text":"Other","start":2878640,"end":2878880,"confidence":0.99316406,"speaker":"A"},{"text":"than","start":2878880,"end":2879080,"confidence":0.99560547,"speaker":"A"},{"text":"that,","start":2879080,"end":2879360,"confidence":0.97509766,"speaker":"A"},{"text":"like","start":2879440,"end":2879840,"confidence":0.7050781,"speaker":"A"},{"text":"have","start":2880370,"end":2880530,"confidence":0.9765625,"speaker":"A"},{"text":"at","start":2880530,"end":2880770,"confidence":0.515625,"speaker":"A"},{"text":"it.","start":2880770,"end":2881090,"confidence":0.9980469,"speaker":"A"},{"text":"So","start":2884610,"end":2884890,"confidence":0.97802734,"speaker":"A"},{"text":"I'm","start":2884890,"end":2885050,"confidence":0.98339844,"speaker":"A"},{"text":"gonna,","start":2885050,"end":2885250,"confidence":0.8352051,"speaker":"A"},{"text":"I'm","start":2885250,"end":2885410,"confidence":0.9949544,"speaker":"A"},{"text":"gonna","start":2885410,"end":2885570,"confidence":0.9736328,"speaker":"A"},{"text":"be","start":2885570,"end":2885690,"confidence":0.99853516,"speaker":"A"},{"text":"devil's","start":2885690,"end":2886050,"confidence":0.9608154,"speaker":"A"},{"text":"advocate.","start":2886050,"end":2886610,"confidence":0.9995117,"speaker":"A"},{"text":"Why","start":2886690,"end":2887010,"confidence":0.99609375,"speaker":"A"},{"text":"not","start":2887010,"end":2887290,"confidence":1,"speaker":"A"},{"text":"just","start":2887290,"end":2887570,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":2887570,"end":2887810,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2887810,"end":2888090,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":2888090,"end":2888770,"confidence":0.87769,"speaker":"A"},{"text":"JavaScript","start":2888850,"end":2889730,"confidence":0.99454755,"speaker":"A"},{"text":"library.","start":2889730,"end":2890210,"confidence":0.8435872,"speaker":"A"},{"text":"If","start":2890210,"end":2890450,"confidence":0.5620117,"speaker":"C"},{"text":"it's","start":2890450,"end":2890690,"confidence":0.9998372,"speaker":"C"},{"text":"an","start":2890690,"end":2890890,"confidence":0.8232422,"speaker":"C"},{"text":"extension,","start":2890890,"end":2891490,"confidence":0.9998372,"speaker":"C"},{"text":"my","start":2892450,"end":2892770,"confidence":0.99853516,"speaker":"C"},{"text":"brain","start":2892770,"end":2893090,"confidence":1,"speaker":"C"},{"text":"jumps","start":2893090,"end":2893450,"confidence":0.9998372,"speaker":"C"},{"text":"to","start":2893450,"end":2893610,"confidence":0.9995117,"speaker":"C"},{"text":"Swift","start":2893610,"end":2893970,"confidence":0.9914551,"speaker":"C"},{"text":"first.","start":2893970,"end":2894290,"confidence":0.9975586,"speaker":"C"},{"text":"Right.","start":2895730,"end":2896129,"confidence":0.97021484,"speaker":"A"},{"text":"But","start":2896129,"end":2896410,"confidence":0.9995117,"speaker":"A"},{"text":"it's","start":2896410,"end":2896730,"confidence":0.96875,"speaker":"A"},{"text":"the","start":2896730,"end":2896970,"confidence":1,"speaker":"A"},{"text":"reason","start":2896970,"end":2897130,"confidence":0.99902344,"speaker":"A"},{"text":"I'm","start":2897130,"end":2897330,"confidence":0.9954427,"speaker":"A"},{"text":"asking","start":2897330,"end":2897610,"confidence":0.97094727,"speaker":"A"},{"text":"that","start":2897610,"end":2897810,"confidence":0.9765625,"speaker":"A"},{"text":"is","start":2897810,"end":2898090,"confidence":0.9980469,"speaker":"A"},{"text":"like","start":2898090,"end":2898370,"confidence":0.9921875,"speaker":"A"},{"text":"it's","start":2898370,"end":2898690,"confidence":0.9900716,"speaker":"A"},{"text":"a,","start":2898690,"end":2898930,"confidence":0.98291016,"speaker":"A"},{"text":"it's","start":2899410,"end":2899770,"confidence":0.9996745,"speaker":"A"},{"text":"already","start":2899770,"end":2899970,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":2899970,"end":2900130,"confidence":0.9995117,"speaker":"A"},{"text":"web","start":2900130,"end":2900410,"confidence":0.98535156,"speaker":"A"},{"text":"extension.","start":2900410,"end":2900890,"confidence":0.9998372,"speaker":"A"},{"text":"I","start":2900890,"end":2901010,"confidence":0.98535156,"speaker":"A"},{"text":"would","start":2901010,"end":2901130,"confidence":0.98095703,"speaker":"A"},{"text":"assume","start":2901130,"end":2901410,"confidence":0.8614909,"speaker":"A"},{"text":"that","start":2901410,"end":2901570,"confidence":0.5854492,"speaker":"A"},{"text":"is","start":2901570,"end":2901690,"confidence":0.80126953,"speaker":"A"},{"text":"true.","start":2901690,"end":2902050,"confidence":0.9968262,"speaker":"A"},{"text":"That","start":2902690,"end":2903090,"confidence":0.9941406,"speaker":"A"},{"text":"it's","start":2903090,"end":2903490,"confidence":0.98876953,"speaker":"A"},{"text":"90","start":2903490,"end":2903810,"confidence":0.99951,"speaker":"A"},{"text":"web","start":2904290,"end":2904650,"confidence":0.9995117,"speaker":"A"},{"text":"based","start":2904650,"end":2904930,"confidence":0.99902344,"speaker":"A"},{"text":"or","start":2905090,"end":2905410,"confidence":0.99853516,"speaker":"A"},{"text":"JavaScript","start":2905410,"end":2906010,"confidence":0.998291,"speaker":"A"},{"text":"based.","start":2906010,"end":2906290,"confidence":0.99902344,"speaker":"A"},{"text":"So","start":2907120,"end":2907200,"confidence":0.9707031,"speaker":"A"},{"text":"that's","start":2907200,"end":2907360,"confidence":0.99934894,"speaker":"A"},{"text":"where","start":2907360,"end":2907480,"confidence":0.9506836,"speaker":"A"},{"text":"I'm","start":2907480,"end":2907680,"confidence":0.99886066,"speaker":"A"},{"text":"just","start":2907680,"end":2907800,"confidence":0.99560547,"speaker":"A"},{"text":"like,","start":2907800,"end":2908000,"confidence":0.99121094,"speaker":"A"},{"text":"well,","start":2908000,"end":2908320,"confidence":0.9951172,"speaker":"A"},{"text":"you","start":2908320,"end":2908600,"confidence":0.99902344,"speaker":"A"},{"text":"may","start":2908600,"end":2908760,"confidence":0.9995117,"speaker":"A"},{"text":"as","start":2908760,"end":2908920,"confidence":0.9995117,"speaker":"A"},{"text":"well.","start":2908920,"end":2909200,"confidence":0.9995117,"speaker":"A"},{"text":"Like,","start":2909200,"end":2909600,"confidence":0.5307617,"speaker":"A"},{"text":"I","start":2909840,"end":2910120,"confidence":0.77685547,"speaker":"A"},{"text":"would","start":2910120,"end":2910280,"confidence":0.99609375,"speaker":"A"},{"text":"love.","start":2910280,"end":2910560,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":2910640,"end":2910880,"confidence":0.97021484,"speaker":"A"},{"text":"don't","start":2910880,"end":2911000,"confidence":0.9313151,"speaker":"A"},{"text":"want","start":2911000,"end":2911120,"confidence":0.9394531,"speaker":"A"},{"text":"to.","start":2911120,"end":2911320,"confidence":0.94433594,"speaker":"A"},{"text":"Like,","start":2911320,"end":2911560,"confidence":0.81689453,"speaker":"A"},{"text":"I","start":2911560,"end":2911680,"confidence":0.99658203,"speaker":"A"},{"text":"love","start":2911680,"end":2911800,"confidence":0.99365234,"speaker":"A"},{"text":"tooting","start":2911800,"end":2912160,"confidence":0.8005371,"speaker":"A"},{"text":"my","start":2912160,"end":2912320,"confidence":1,"speaker":"A"},{"text":"own","start":2912320,"end":2912480,"confidence":1,"speaker":"A"},{"text":"horn.","start":2912480,"end":2912800,"confidence":0.9995117,"speaker":"A"},{"text":"Right.","start":2912800,"end":2913040,"confidence":0.9838867,"speaker":"A"},{"text":"But","start":2913040,"end":2913280,"confidence":0.9951172,"speaker":"A"},{"text":"like,","start":2913280,"end":2913600,"confidence":0.94628906,"speaker":"A"},{"text":"like","start":2914880,"end":2915280,"confidence":0.82666016,"speaker":"A"},{"text":"why","start":2915280,"end":2915560,"confidence":0.9951172,"speaker":"A"},{"text":"not","start":2915560,"end":2915800,"confidence":0.87939453,"speaker":"A"},{"text":"just.","start":2915800,"end":2916160,"confidence":0.9975586,"speaker":"A"},{"text":"Unless","start":2916320,"end":2916720,"confidence":0.92749023,"speaker":"A"},{"text":"you're.","start":2916720,"end":2917120,"confidence":0.9876302,"speaker":"A"},{"text":"Unless","start":2920720,"end":2921080,"confidence":0.998291,"speaker":"A"},{"text":"you're","start":2921080,"end":2921440,"confidence":0.90478516,"speaker":"A"},{"text":"like","start":2921440,"end":2921840,"confidence":0.94628906,"speaker":"A"},{"text":"building","start":2922000,"end":2922400,"confidence":1,"speaker":"A"},{"text":"a","start":2922480,"end":2922879,"confidence":0.6621094,"speaker":"A"},{"text":"executable,","start":2923040,"end":2923840,"confidence":0.9987793,"speaker":"A"},{"text":"I","start":2924160,"end":2924440,"confidence":0.99316406,"speaker":"A"},{"text":"guess,","start":2924440,"end":2924800,"confidence":1,"speaker":"A"},{"text":"or","start":2924800,"end":2925080,"confidence":0.9970703,"speaker":"A"},{"text":"an","start":2925080,"end":2925240,"confidence":0.9628906,"speaker":"A"},{"text":"app.","start":2925240,"end":2925480,"confidence":0.93652344,"speaker":"A"},{"text":"Ish.","start":2925480,"end":2925920,"confidence":0.7595215,"speaker":"A"},{"text":"And","start":2927760,"end":2928080,"confidence":0.9038086,"speaker":"C"},{"text":"I","start":2928080,"end":2928400,"confidence":0.64697266,"speaker":"C"},{"text":"guess","start":2928400,"end":2928800,"confidence":1,"speaker":"C"},{"text":"another","start":2928800,"end":2929120,"confidence":1,"speaker":"C"},{"text":"application","start":2929120,"end":2929760,"confidence":1,"speaker":"C"},{"text":"for","start":2929760,"end":2930000,"confidence":1,"speaker":"C"},{"text":"this","start":2930000,"end":2930240,"confidence":1,"speaker":"C"},{"text":"would","start":2930240,"end":2930560,"confidence":0.9995117,"speaker":"C"},{"text":"be","start":2930560,"end":2930960,"confidence":0.9995117,"speaker":"C"},{"text":"doing","start":2931680,"end":2932040,"confidence":0.9995117,"speaker":"C"},{"text":"CloudKit","start":2932040,"end":2932680,"confidence":0.99902344,"speaker":"C"},{"text":"stuff","start":2932680,"end":2933000,"confidence":0.9954427,"speaker":"C"},{"text":"server","start":2933000,"end":2933360,"confidence":0.9074707,"speaker":"C"},{"text":"side","start":2933360,"end":2933640,"confidence":1,"speaker":"C"},{"text":"and","start":2933640,"end":2934000,"confidence":0.9243164,"speaker":"C"},{"text":"then","start":2934000,"end":2934400,"confidence":0.9995117,"speaker":"C"},{"text":"providing","start":2934400,"end":2934880,"confidence":0.8515625,"speaker":"C"},{"text":"my","start":2934880,"end":2935120,"confidence":0.9995117,"speaker":"C"},{"text":"own","start":2935120,"end":2935400,"confidence":1,"speaker":"C"},{"text":"API","start":2935400,"end":2935920,"confidence":1,"speaker":"C"},{"text":"layer","start":2935920,"end":2936280,"confidence":0.9995117,"speaker":"C"},{"text":"over","start":2936280,"end":2936480,"confidence":1,"speaker":"C"},{"text":"it.","start":2936480,"end":2936800,"confidence":0.99853516,"speaker":"C"},{"text":"Yep,","start":2937660,"end":2938060,"confidence":0.8959961,"speaker":"A"},{"text":"yep.","start":2938220,"end":2938700,"confidence":0.7453613,"speaker":"A"},{"text":"So","start":2938940,"end":2939340,"confidence":0.9946289,"speaker":"A"},{"text":"that's.","start":2939340,"end":2939860,"confidence":0.9943034,"speaker":"A"},{"text":"Yeah.","start":2939860,"end":2940300,"confidence":0.99316406,"speaker":"A"},{"text":"Are","start":2940460,"end":2940700,"confidence":0.99658203,"speaker":"A"},{"text":"we","start":2940700,"end":2940820,"confidence":0.9995117,"speaker":"A"},{"text":"talking","start":2940820,"end":2941180,"confidence":0.9992676,"speaker":"A"},{"text":"private","start":2941340,"end":2941660,"confidence":0.99902344,"speaker":"A"},{"text":"database","start":2941660,"end":2942180,"confidence":0.9998372,"speaker":"A"},{"text":"or","start":2942180,"end":2942340,"confidence":0.9970703,"speaker":"A"},{"text":"public","start":2942340,"end":2942540,"confidence":0.9995117,"speaker":"A"},{"text":"database?","start":2942540,"end":2943180,"confidence":0.9995117,"speaker":"A"},{"text":"Private.","start":2943340,"end":2943740,"confidence":0.99609375,"speaker":"C"},{"text":"So","start":2945580,"end":2945820,"confidence":0.99902344,"speaker":"A"},{"text":"in","start":2945820,"end":2945940,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":2945940,"end":2946140,"confidence":0.9995117,"speaker":"A"},{"text":"case,","start":2946140,"end":2946460,"confidence":1,"speaker":"A"},{"text":"basically","start":2946700,"end":2947340,"confidence":0.99975586,"speaker":"A"},{"text":"like","start":2948060,"end":2948340,"confidence":0.99853516,"speaker":"A"},{"text":"you'd","start":2948340,"end":2948660,"confidence":0.99690753,"speaker":"A"},{"text":"have","start":2948660,"end":2948780,"confidence":1,"speaker":"A"},{"text":"to","start":2948780,"end":2948900,"confidence":1,"speaker":"A"},{"text":"go","start":2948900,"end":2949140,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2949140,"end":2949380,"confidence":0.99902344,"speaker":"A"},{"text":"Hard","start":2949380,"end":2949580,"confidence":0.8798828,"speaker":"A"},{"text":"Twitch","start":2949580,"end":2949940,"confidence":0.9433594,"speaker":"A"},{"text":"route","start":2949940,"end":2950300,"confidence":0.9946289,"speaker":"A"},{"text":"and","start":2951100,"end":2951500,"confidence":0.9951172,"speaker":"A"},{"text":"you","start":2952460,"end":2952740,"confidence":0.99853516,"speaker":"A"},{"text":"would","start":2952740,"end":2952979,"confidence":0.8515625,"speaker":"A"},{"text":"have","start":2952979,"end":2953219,"confidence":1,"speaker":"A"},{"text":"to","start":2953219,"end":2953380,"confidence":1,"speaker":"A"},{"text":"provide","start":2953380,"end":2953660,"confidence":1,"speaker":"A"},{"text":"a","start":2953900,"end":2954180,"confidence":0.9760742,"speaker":"A"},{"text":"way","start":2954180,"end":2954460,"confidence":0.9975586,"speaker":"A"},{"text":"to","start":2955980,"end":2956260,"confidence":0.9975586,"speaker":"A"},{"text":"get","start":2956260,"end":2956420,"confidence":1,"speaker":"A"},{"text":"their","start":2956420,"end":2956580,"confidence":0.9921875,"speaker":"A"},{"text":"web","start":2956580,"end":2956820,"confidence":0.9992676,"speaker":"A"},{"text":"authentication","start":2956820,"end":2957420,"confidence":0.9996338,"speaker":"A"},{"text":"token,","start":2957420,"end":2957980,"confidence":0.99820966,"speaker":"A"},{"text":"essentially,","start":2958460,"end":2959060,"confidence":0.9316406,"speaker":"A"},{"text":"if","start":2959060,"end":2959260,"confidence":0.9770508,"speaker":"A"},{"text":"that","start":2959260,"end":2959380,"confidence":0.9995117,"speaker":"A"},{"text":"makes","start":2959380,"end":2959540,"confidence":0.9970703,"speaker":"A"},{"text":"sense.","start":2959540,"end":2959900,"confidence":0.99853516,"speaker":"A"},{"text":"And","start":2960540,"end":2960820,"confidence":0.9975586,"speaker":"A"},{"text":"then","start":2960820,"end":2961020,"confidence":0.99902344,"speaker":"A"},{"text":"store","start":2961020,"end":2961260,"confidence":0.99853516,"speaker":"A"},{"text":"it","start":2961260,"end":2961380,"confidence":0.9980469,"speaker":"A"},{"text":"in","start":2961380,"end":2961540,"confidence":0.9980469,"speaker":"A"},{"text":"Postgres","start":2961540,"end":2962020,"confidence":0.98046875,"speaker":"A"},{"text":"or","start":2962020,"end":2962180,"confidence":0.9970703,"speaker":"A"},{"text":"whatever","start":2962180,"end":2962380,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":2962380,"end":2962500,"confidence":0.99902344,"speaker":"A"},{"text":"hell","start":2962500,"end":2962700,"confidence":0.99975586,"speaker":"A"},{"text":"you","start":2962700,"end":2962820,"confidence":0.9995117,"speaker":"A"},{"text":"want","start":2962820,"end":2962980,"confidence":0.97802734,"speaker":"A"},{"text":"to","start":2962980,"end":2963100,"confidence":0.9980469,"speaker":"A"},{"text":"do.","start":2963100,"end":2963260,"confidence":0.9995117,"speaker":"A"},{"text":"Like","start":2963260,"end":2963500,"confidence":0.99121094,"speaker":"A"},{"text":"that's,","start":2963500,"end":2963820,"confidence":0.98876953,"speaker":"A"},{"text":"that's","start":2963820,"end":2964060,"confidence":0.99658203,"speaker":"A"},{"text":"the","start":2964060,"end":2964140,"confidence":0.99902344,"speaker":"A"},{"text":"way","start":2964140,"end":2964220,"confidence":1,"speaker":"A"},{"text":"I","start":2964220,"end":2964340,"confidence":0.9995117,"speaker":"A"},{"text":"did","start":2964340,"end":2964460,"confidence":0.9941406,"speaker":"A"},{"text":"it","start":2964460,"end":2964540,"confidence":0.9946289,"speaker":"A"},{"text":"with","start":2964540,"end":2964660,"confidence":0.9995117,"speaker":"A"},{"text":"Hard","start":2964660,"end":2964820,"confidence":0.8378906,"speaker":"A"},{"text":"Twitch.","start":2964820,"end":2965260,"confidence":0.88256836,"speaker":"A"},{"text":"But","start":2966400,"end":2966480,"confidence":0.96484375,"speaker":"A"},{"text":"once","start":2966480,"end":2966600,"confidence":0.9897461,"speaker":"A"},{"text":"you","start":2966600,"end":2966760,"confidence":0.9946289,"speaker":"A"},{"text":"have","start":2966760,"end":2966880,"confidence":0.8364258,"speaker":"A"},{"text":"that,","start":2966880,"end":2967120,"confidence":0.5385742,"speaker":"A"},{"text":"you","start":2967120,"end":2967360,"confidence":0.9995117,"speaker":"A"},{"text":"can","start":2967360,"end":2967440,"confidence":0.99902344,"speaker":"A"},{"text":"do","start":2967440,"end":2967520,"confidence":0.9995117,"speaker":"A"},{"text":"anything","start":2967520,"end":2967760,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":2967760,"end":2967880,"confidence":0.9970703,"speaker":"A"},{"text":"want","start":2967880,"end":2968080,"confidence":0.99658203,"speaker":"A"},{"text":"on","start":2968080,"end":2968280,"confidence":0.99853516,"speaker":"A"},{"text":"the","start":2968280,"end":2968440,"confidence":0.99316406,"speaker":"A"},{"text":"server","start":2968440,"end":2968880,"confidence":0.99975586,"speaker":"A"},{"text":"with","start":2969200,"end":2969520,"confidence":0.9980469,"speaker":"A"},{"text":"their","start":2969520,"end":2969840,"confidence":0.98583984,"speaker":"A"},{"text":"private","start":2970240,"end":2970600,"confidence":0.99853516,"speaker":"A"},{"text":"database,","start":2970600,"end":2971200,"confidence":0.9996745,"speaker":"A"},{"text":"if","start":2971200,"end":2971400,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":2971400,"end":2971560,"confidence":0.9995117,"speaker":"A"},{"text":"makes","start":2971560,"end":2971720,"confidence":0.9970703,"speaker":"A"},{"text":"sense.","start":2971720,"end":2972080,"confidence":0.99902344,"speaker":"A"},{"text":"It","start":2972560,"end":2972840,"confidence":0.9692383,"speaker":"C"},{"text":"does.","start":2972840,"end":2973120,"confidence":0.9980469,"speaker":"C"},{"text":"Yep.","start":2973920,"end":2974480,"confidence":0.8156738,"speaker":"A"},{"text":"Yep.","start":2974560,"end":2975120,"confidence":0.7368164,"speaker":"A"},{"text":"A","start":2975920,"end":2976160,"confidence":0.5620117,"speaker":"A"},{"text":"couple","start":2976160,"end":2976360,"confidence":0.99731445,"speaker":"A"},{"text":"of","start":2976360,"end":2976480,"confidence":0.9433594,"speaker":"A"},{"text":"things","start":2976480,"end":2976720,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":2977040,"end":2977320,"confidence":0.9980469,"speaker":"A"},{"text":"wanted","start":2977320,"end":2977560,"confidence":0.9992676,"speaker":"A"},{"text":"to","start":2977560,"end":2977720,"confidence":0.9995117,"speaker":"A"},{"text":"bring","start":2977720,"end":2977920,"confidence":1,"speaker":"A"},{"text":"up,","start":2977920,"end":2978240,"confidence":0.9995117,"speaker":"A"},{"text":"so","start":2978320,"end":2978640,"confidence":0.9765625,"speaker":"A"},{"text":"let's","start":2978640,"end":2978920,"confidence":0.99902344,"speaker":"A"},{"text":"take","start":2978920,"end":2979080,"confidence":1,"speaker":"A"},{"text":"a","start":2979080,"end":2979240,"confidence":1,"speaker":"A"},{"text":"look.","start":2979240,"end":2979520,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":2984000,"end":2984400,"confidence":0.95214844,"speaker":"A"},{"text":"part","start":2986880,"end":2987160,"confidence":0.99902344,"speaker":"A"},{"text":"of","start":2987160,"end":2987280,"confidence":1,"speaker":"A"},{"text":"my","start":2987280,"end":2987400,"confidence":1,"speaker":"A"},{"text":"other","start":2987400,"end":2987640,"confidence":1,"speaker":"A"},{"text":"presentation","start":2987640,"end":2988400,"confidence":1,"speaker":"A"},{"text":"is","start":2988640,"end":2989040,"confidence":0.99853516,"speaker":"A"},{"text":"working,","start":2990000,"end":2990400,"confidence":0.87841797,"speaker":"A"},{"text":"talking","start":2990800,"end":2991160,"confidence":0.7766113,"speaker":"A"},{"text":"about","start":2991160,"end":2991440,"confidence":0.9951172,"speaker":"A"},{"text":"cross","start":2991640,"end":2991880,"confidence":0.998291,"speaker":"A"},{"text":"platform","start":2991880,"end":2992360,"confidence":0.8640137,"speaker":"A"},{"text":"automation","start":2992600,"end":2993320,"confidence":0.9996745,"speaker":"A"},{"text":"type","start":2993640,"end":2994000,"confidence":0.9980469,"speaker":"A"},{"text":"stuff.","start":2994000,"end":2994440,"confidence":1,"speaker":"A"},{"text":"And","start":2995560,"end":2995960,"confidence":0.9868164,"speaker":"A"},{"text":"the","start":2996440,"end":2996760,"confidence":0.9995117,"speaker":"A"},{"text":"one","start":2996760,"end":2997040,"confidence":1,"speaker":"A"},{"text":"issue","start":2997040,"end":2997400,"confidence":0.9995117,"speaker":"A"},{"text":"I've","start":2997400,"end":2997840,"confidence":0.9972331,"speaker":"A"},{"text":"run","start":2997840,"end":2998040,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":2998040,"end":2998360,"confidence":1,"speaker":"A"},{"text":"is.","start":2998440,"end":2998840,"confidence":0.9926758,"speaker":"A"},{"text":"So","start":2998920,"end":2999200,"confidence":0.9921875,"speaker":"A"},{"text":"it","start":2999200,"end":2999360,"confidence":0.9916992,"speaker":"A"},{"text":"basically","start":2999360,"end":2999800,"confidence":0.99975586,"speaker":"A"},{"text":"builds","start":2999800,"end":3000160,"confidence":0.9992676,"speaker":"A"},{"text":"on","start":3000160,"end":3000360,"confidence":0.9995117,"speaker":"A"},{"text":"everything.","start":3000360,"end":3000680,"confidence":1,"speaker":"A"},{"text":"Right","start":3000920,"end":3001240,"confidence":0.9995117,"speaker":"A"},{"text":"now.","start":3001240,"end":3001560,"confidence":0.9995117,"speaker":"A"},{"text":"I'm","start":3007560,"end":3007880,"confidence":0.9977214,"speaker":"A"},{"text":"going","start":3007880,"end":3007960,"confidence":0.6772461,"speaker":"A"},{"text":"to","start":3007960,"end":3008080,"confidence":0.9975586,"speaker":"A"},{"text":"share","start":3008080,"end":3008320,"confidence":0.9995117,"speaker":"A"},{"text":"something.","start":3008320,"end":3008680,"confidence":0.9995117,"speaker":"A"},{"text":"Hey","start":3009880,"end":3010200,"confidence":0.99609375,"speaker":"B"},{"text":"guys,","start":3010200,"end":3010520,"confidence":0.99902344,"speaker":"B"},{"text":"I","start":3011000,"end":3011240,"confidence":0.9770508,"speaker":"B"},{"text":"got","start":3011240,"end":3011320,"confidence":0.99609375,"speaker":"B"},{"text":"to","start":3011320,"end":3011400,"confidence":0.44458008,"speaker":"B"},{"text":"drop.","start":3011400,"end":3011720,"confidence":0.9885254,"speaker":"B"},{"text":"But","start":3011800,"end":3012160,"confidence":0.98291016,"speaker":"B"},{"text":"it","start":3012160,"end":3012400,"confidence":0.9995117,"speaker":"B"},{"text":"was","start":3012400,"end":3012680,"confidence":0.9995117,"speaker":"B"},{"text":"good","start":3012680,"end":3013000,"confidence":0.9995117,"speaker":"B"},{"text":"presentation,","start":3013000,"end":3013480,"confidence":0.9995117,"speaker":"B"},{"text":"Leo.","start":3013480,"end":3014040,"confidence":0.9987793,"speaker":"B"},{"text":"Thank","start":3014040,"end":3014400,"confidence":0.99975586,"speaker":"B"},{"text":"you.","start":3014400,"end":3014680,"confidence":0.9975586,"speaker":"B"},{"text":"Yeah,","start":3014840,"end":3015240,"confidence":0.99088544,"speaker":"A"},{"text":"yeah.","start":3015240,"end":3015560,"confidence":0.9458008,"speaker":"A"},{"text":"If","start":3015560,"end":3015720,"confidence":0.88964844,"speaker":"A"},{"text":"I","start":3015720,"end":3015840,"confidence":0.98876953,"speaker":"A"},{"text":"have","start":3015840,"end":3015960,"confidence":0.9169922,"speaker":"A"},{"text":"more","start":3015960,"end":3016040,"confidence":0.97265625,"speaker":"A"},{"text":"questions,","start":3016040,"end":3016320,"confidence":0.95996094,"speaker":"A"},{"text":"if","start":3016320,"end":3016440,"confidence":0.9589844,"speaker":"A"},{"text":"you","start":3016440,"end":3016520,"confidence":0.9951172,"speaker":"A"},{"text":"have","start":3016520,"end":3016640,"confidence":0.9980469,"speaker":"A"},{"text":"any","start":3016640,"end":3016800,"confidence":0.9995117,"speaker":"A"},{"text":"feedback,","start":3016800,"end":3017160,"confidence":0.9996338,"speaker":"A"},{"text":"just","start":3017160,"end":3017360,"confidence":0.9995117,"speaker":"A"},{"text":"hit","start":3017360,"end":3017520,"confidence":1,"speaker":"A"},{"text":"me","start":3017520,"end":3017640,"confidence":1,"speaker":"A"},{"text":"up","start":3017640,"end":3017760,"confidence":1,"speaker":"A"},{"text":"on","start":3017760,"end":3018040,"confidence":0.99658203,"speaker":"A"},{"text":"Slack.","start":3018950,"end":3019350,"confidence":0.89697266,"speaker":"A"},{"text":"Sounds","start":3019590,"end":3019990,"confidence":0.9978841,"speaker":"B"},{"text":"good.","start":3019990,"end":3020150,"confidence":0.9980469,"speaker":"B"},{"text":"Cool,","start":3020150,"end":3020470,"confidence":0.9345703,"speaker":"A"},{"text":"thank","start":3020470,"end":3020750,"confidence":0.7890625,"speaker":"A"},{"text":"you.","start":3020750,"end":3020950,"confidence":0.99316406,"speaker":"A"},{"text":"Thank","start":3020950,"end":3021230,"confidence":0.94628906,"speaker":"A"},{"text":"you","start":3021230,"end":3021350,"confidence":0.9995117,"speaker":"A"},{"text":"so","start":3021350,"end":3021470,"confidence":0.99853516,"speaker":"A"},{"text":"much","start":3021470,"end":3021590,"confidence":1,"speaker":"A"},{"text":"for","start":3021590,"end":3021710,"confidence":0.9995117,"speaker":"A"},{"text":"helping","start":3021710,"end":3021950,"confidence":0.99975586,"speaker":"A"},{"text":"me","start":3021950,"end":3022150,"confidence":0.81103516,"speaker":"A"},{"text":"set","start":3022150,"end":3022350,"confidence":0.96240234,"speaker":"A"},{"text":"this","start":3022350,"end":3022510,"confidence":0.99365234,"speaker":"A"},{"text":"up.","start":3022510,"end":3022790,"confidence":0.99902344,"speaker":"A"},{"text":"Yeah,","start":3023590,"end":3023990,"confidence":0.95214844,"speaker":"A"},{"text":"talk","start":3023990,"end":3024190,"confidence":0.9824219,"speaker":"A"},{"text":"to","start":3024190,"end":3024350,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":3024350,"end":3024470,"confidence":0.99658203,"speaker":"A"},{"text":"later.","start":3024470,"end":3024710,"confidence":0.9838867,"speaker":"A"},{"text":"Thank","start":3024950,"end":3025230,"confidence":0.9968262,"speaker":"B"},{"text":"you.","start":3025230,"end":3025350,"confidence":0.99902344,"speaker":"B"},{"text":"Bye","start":3025350,"end":3025590,"confidence":0.9824219,"speaker":"B"},{"text":"bye.","start":3025590,"end":3025910,"confidence":0.99316406,"speaker":"B"},{"text":"Yeah,","start":3028870,"end":3029190,"confidence":0.88216144,"speaker":"C"},{"text":"so","start":3029190,"end":3029310,"confidence":0.91308594,"speaker":"C"},{"text":"if","start":3029310,"end":3029430,"confidence":0.99609375,"speaker":"C"},{"text":"you","start":3029430,"end":3029510,"confidence":0.99365234,"speaker":"C"},{"text":"had","start":3029510,"end":3029630,"confidence":0.9638672,"speaker":"C"},{"text":"something","start":3029630,"end":3029830,"confidence":0.9995117,"speaker":"C"},{"text":"else","start":3029830,"end":3030070,"confidence":0.99975586,"speaker":"C"},{"text":"to","start":3030070,"end":3030190,"confidence":0.99853516,"speaker":"C"},{"text":"show,","start":3030190,"end":3030350,"confidence":0.99902344,"speaker":"C"},{"text":"I'm","start":3030350,"end":3030550,"confidence":0.99869794,"speaker":"C"},{"text":"happy","start":3030550,"end":3030750,"confidence":0.9995117,"speaker":"C"},{"text":"to","start":3030750,"end":3030990,"confidence":0.6503906,"speaker":"C"},{"text":"look","start":3030990,"end":3031230,"confidence":0.97021484,"speaker":"C"},{"text":"for.","start":3031230,"end":3031430,"confidence":0.79541016,"speaker":"C"},{"text":"I'm","start":3031430,"end":3031670,"confidence":0.99104816,"speaker":"C"},{"text":"here","start":3031670,"end":3031790,"confidence":0.9995117,"speaker":"C"},{"text":"for","start":3031790,"end":3031910,"confidence":0.9995117,"speaker":"C"},{"text":"a","start":3031910,"end":3031990,"confidence":0.9980469,"speaker":"C"},{"text":"few","start":3031990,"end":3032110,"confidence":0.9995117,"speaker":"C"},{"text":"more","start":3032110,"end":3032270,"confidence":0.9995117,"speaker":"C"},{"text":"minutes","start":3032270,"end":3032510,"confidence":0.9987793,"speaker":"C"},{"text":"as","start":3032510,"end":3032670,"confidence":0.99853516,"speaker":"C"},{"text":"well.","start":3032670,"end":3032950,"confidence":0.99902344,"speaker":"C"},{"text":"Yeah,","start":3033590,"end":3033910,"confidence":0.96402997,"speaker":"A"},{"text":"yeah,","start":3033910,"end":3034070,"confidence":0.90755206,"speaker":"A"},{"text":"yeah.","start":3034070,"end":3034390,"confidence":0.8152669,"speaker":"A"},{"text":"So","start":3038790,"end":3039110,"confidence":0.94628906,"speaker":"A"},{"text":"I","start":3039110,"end":3039350,"confidence":0.9995117,"speaker":"A"},{"text":"have","start":3039350,"end":3039630,"confidence":1,"speaker":"A"},{"text":"the","start":3039630,"end":3039870,"confidence":0.9980469,"speaker":"A"},{"text":"workflow","start":3039870,"end":3040350,"confidence":0.9995117,"speaker":"A"},{"text":"working","start":3040350,"end":3040630,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":3041190,"end":3041590,"confidence":0.99853516,"speaker":"A"},{"text":"and","start":3041670,"end":3041950,"confidence":0.9892578,"speaker":"A"},{"text":"it","start":3041950,"end":3042070,"confidence":0.9995117,"speaker":"A"},{"text":"does","start":3042070,"end":3042270,"confidence":0.99902344,"speaker":"A"},{"text":"Ubuntu,","start":3042270,"end":3043110,"confidence":0.9856445,"speaker":"A"},{"text":"it","start":3044080,"end":3044200,"confidence":0.97216797,"speaker":"A"},{"text":"does","start":3044200,"end":3044400,"confidence":0.99853516,"speaker":"A"},{"text":"Windows,","start":3044400,"end":3044960,"confidence":0.9944661,"speaker":"A"},{"text":"it","start":3045120,"end":3045400,"confidence":0.99365234,"speaker":"A"},{"text":"does","start":3045400,"end":3045600,"confidence":0.98779297,"speaker":"A"},{"text":"Android.","start":3045600,"end":3046120,"confidence":0.9943034,"speaker":"A"},{"text":"So","start":3046120,"end":3046360,"confidence":0.98046875,"speaker":"A"},{"text":"all","start":3046360,"end":3046480,"confidence":0.99853516,"speaker":"A"},{"text":"that","start":3046480,"end":3046600,"confidence":0.9975586,"speaker":"A"},{"text":"stuff","start":3046600,"end":3046880,"confidence":0.90494794,"speaker":"A"},{"text":"is","start":3046880,"end":3047080,"confidence":0.9995117,"speaker":"A"},{"text":"available","start":3047080,"end":3047360,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3047440,"end":3047720,"confidence":0.99902344,"speaker":"A"},{"text":"you.","start":3047720,"end":3048000,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3048640,"end":3048960,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":3048960,"end":3049200,"confidence":0.9995117,"speaker":"A"},{"text":"never","start":3049200,"end":3049440,"confidence":1,"speaker":"A"},{"text":"recommend","start":3049440,"end":3049920,"confidence":0.9998372,"speaker":"A"},{"text":"using","start":3049920,"end":3050240,"confidence":0.99902344,"speaker":"A"},{"text":"Miskit","start":3050240,"end":3050920,"confidence":0.9777832,"speaker":"A"},{"text":"on","start":3050920,"end":3051160,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":3051160,"end":3051320,"confidence":0.99902344,"speaker":"A"},{"text":"Apple","start":3051320,"end":3051560,"confidence":1,"speaker":"A"},{"text":"platform","start":3051560,"end":3052040,"confidence":0.9992676,"speaker":"A"},{"text":"for","start":3052040,"end":3052280,"confidence":0.9995117,"speaker":"A"},{"text":"obvious","start":3052280,"end":3052640,"confidence":0.99975586,"speaker":"A"},{"text":"reasons,","start":3052640,"end":3053200,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":3053280,"end":3053600,"confidence":0.9238281,"speaker":"A"},{"text":"what's","start":3053600,"end":3053840,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3053840,"end":3053960,"confidence":0.9995117,"speaker":"A"},{"text":"point?","start":3053960,"end":3054240,"confidence":0.99902344,"speaker":"A"},{"text":"True.","start":3055600,"end":3056080,"confidence":0.9099121,"speaker":"C"},{"text":"Unless","start":3056080,"end":3056440,"confidence":0.99609375,"speaker":"A"},{"text":"there's","start":3056440,"end":3056720,"confidence":0.9946289,"speaker":"A"},{"text":"something","start":3056720,"end":3056920,"confidence":1,"speaker":"A"},{"text":"special","start":3056920,"end":3057240,"confidence":1,"speaker":"A"},{"text":"that","start":3057240,"end":3057480,"confidence":0.9970703,"speaker":"A"},{"text":"I","start":3057480,"end":3057640,"confidence":0.9995117,"speaker":"A"},{"text":"provide","start":3057640,"end":3057880,"confidence":1,"speaker":"A"},{"text":"that","start":3057880,"end":3058160,"confidence":0.9897461,"speaker":"A"},{"text":"CloudKit","start":3058160,"end":3058760,"confidence":0.89551,"speaker":"A"},{"text":"doesn't","start":3058760,"end":3059040,"confidence":0.96777344,"speaker":"A"},{"text":"like,","start":3059040,"end":3059360,"confidence":0.83496094,"speaker":"A"},{"text":"I","start":3059440,"end":3059680,"confidence":0.99560547,"speaker":"A"},{"text":"don't","start":3059680,"end":3059920,"confidence":0.8590495,"speaker":"A"},{"text":"get","start":3059920,"end":3060039,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":3060039,"end":3060320,"confidence":0.9980469,"speaker":"A"},{"text":"Right.","start":3060480,"end":3060880,"confidence":0.8925781,"speaker":"C"},{"text":"But","start":3061200,"end":3061600,"confidence":0.9941406,"speaker":"A"},{"text":"we","start":3062560,"end":3062880,"confidence":0.9926758,"speaker":"A"},{"text":"have","start":3062880,"end":3063200,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":3063200,"end":3063520,"confidence":0.9770508,"speaker":"A"},{"text":"issue.","start":3063520,"end":3063840,"confidence":0.9765625,"speaker":"A"},{"text":"So","start":3063920,"end":3064200,"confidence":0.9794922,"speaker":"A"},{"text":"I","start":3064200,"end":3064360,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":3064360,"end":3064560,"confidence":0.99902344,"speaker":"A"},{"text":"started","start":3064560,"end":3064840,"confidence":0.9995117,"speaker":"A"},{"text":"dabbling.","start":3064840,"end":3065440,"confidence":0.91918945,"speaker":"A"},{"text":"I","start":3066000,"end":3066280,"confidence":0.609375,"speaker":"A"},{"text":"haven't","start":3066280,"end":3066520,"confidence":0.9489746,"speaker":"A"},{"text":"really","start":3066520,"end":3066800,"confidence":0.9975586,"speaker":"A"},{"text":"done","start":3066960,"end":3067280,"confidence":1,"speaker":"A"},{"text":"anything","start":3067280,"end":3067640,"confidence":1,"speaker":"A"},{"text":"with","start":3067640,"end":3067840,"confidence":0.9995117,"speaker":"A"},{"text":"wasm,","start":3067840,"end":3068480,"confidence":0.6376953,"speaker":"A"},{"text":"but","start":3069450,"end":3069530,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3069530,"end":3069650,"confidence":0.9980469,"speaker":"A"},{"text":"did","start":3069650,"end":3069810,"confidence":0.99853516,"speaker":"A"},{"text":"definitely","start":3069810,"end":3070210,"confidence":0.83239746,"speaker":"A"},{"text":"try.","start":3070210,"end":3070570,"confidence":0.99902344,"speaker":"A"},{"text":"Like","start":3070570,"end":3070850,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3070850,"end":3071010,"confidence":0.99609375,"speaker":"A"},{"text":"added","start":3071010,"end":3071250,"confidence":0.99902344,"speaker":"A"},{"text":"support","start":3071250,"end":3071530,"confidence":0.99853516,"speaker":"A"},{"text":"for","start":3071530,"end":3071730,"confidence":0.99853516,"speaker":"A"},{"text":"WASM","start":3071730,"end":3072250,"confidence":0.5599365,"speaker":"A"},{"text":"in","start":3072250,"end":3072450,"confidence":0.9560547,"speaker":"A"},{"text":"my,","start":3072450,"end":3072730,"confidence":0.9975586,"speaker":"A"},{"text":"in","start":3072730,"end":3073050,"confidence":0.9980469,"speaker":"A"},{"text":"my","start":3073050,"end":3073370,"confidence":1,"speaker":"A"},{"text":"Swift","start":3073690,"end":3074210,"confidence":0.9980469,"speaker":"A"},{"text":"build","start":3074210,"end":3074530,"confidence":0.99609375,"speaker":"A"},{"text":"action.","start":3074530,"end":3074890,"confidence":0.99902344,"speaker":"A"},{"text":"The","start":3077210,"end":3077490,"confidence":0.99121094,"speaker":"A"},{"text":"thing","start":3077490,"end":3077650,"confidence":0.9980469,"speaker":"A"},{"text":"about","start":3077650,"end":3077930,"confidence":0.9995117,"speaker":"A"},{"text":"WASA","start":3077930,"end":3078650,"confidence":0.66918945,"speaker":"A"},{"text":"is","start":3078650,"end":3078850,"confidence":0.9995117,"speaker":"A"},{"text":"it","start":3078850,"end":3079010,"confidence":0.99853516,"speaker":"A"},{"text":"does","start":3079010,"end":3079210,"confidence":0.99853516,"speaker":"A"},{"text":"not","start":3079210,"end":3079410,"confidence":0.99560547,"speaker":"A"},{"text":"provide.","start":3079410,"end":3079690,"confidence":0.99902344,"speaker":"A"},{"text":"It","start":3079770,"end":3080050,"confidence":0.99609375,"speaker":"A"},{"text":"doesn't","start":3080050,"end":3080290,"confidence":0.9978841,"speaker":"A"},{"text":"have","start":3080290,"end":3080410,"confidence":1,"speaker":"A"},{"text":"a","start":3080410,"end":3080530,"confidence":0.99853516,"speaker":"A"},{"text":"transport","start":3080530,"end":3081050,"confidence":0.99853516,"speaker":"A"},{"text":"available.","start":3081130,"end":3081530,"confidence":0.9995117,"speaker":"A"},{"text":"So","start":3082570,"end":3082850,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":3082850,"end":3083050,"confidence":0.99853516,"speaker":"A"},{"text":"talked","start":3083050,"end":3083290,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":3083290,"end":3083490,"confidence":0.9995117,"speaker":"A"},{"text":"transports,","start":3083490,"end":3084410,"confidence":0.9938151,"speaker":"A"},{"text":"I","start":3086010,"end":3086250,"confidence":0.9770508,"speaker":"A"},{"text":"think.","start":3086250,"end":3086490,"confidence":0.9980469,"speaker":"A"},{"text":"Did","start":3086570,"end":3086850,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":3086850,"end":3087010,"confidence":1,"speaker":"A"},{"text":"hear","start":3087010,"end":3087170,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":3087170,"end":3087330,"confidence":0.99902344,"speaker":"A"},{"text":"that","start":3087330,"end":3087530,"confidence":0.9970703,"speaker":"A"},{"text":"part","start":3087530,"end":3087770,"confidence":0.9995117,"speaker":"A"},{"text":"about","start":3087770,"end":3087970,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3087970,"end":3088090,"confidence":0.9995117,"speaker":"A"},{"text":"Open","start":3088090,"end":3088250,"confidence":0.99902344,"speaker":"A"},{"text":"API","start":3088250,"end":3088770,"confidence":0.7873535,"speaker":"A"},{"text":"generator","start":3088770,"end":3089170,"confidence":0.9995117,"speaker":"A"},{"text":"and","start":3089170,"end":3089330,"confidence":0.95751953,"speaker":"A"},{"text":"transports?","start":3089330,"end":3090090,"confidence":0.8383789,"speaker":"A"},{"text":"I","start":3091370,"end":3091770,"confidence":0.9667969,"speaker":"C"},{"text":"think","start":3091850,"end":3092170,"confidence":0.9995117,"speaker":"C"},{"text":"I","start":3092170,"end":3092370,"confidence":0.9970703,"speaker":"C"},{"text":"was","start":3092370,"end":3092570,"confidence":1,"speaker":"C"},{"text":"coming","start":3092570,"end":3092810,"confidence":0.9995117,"speaker":"C"},{"text":"in","start":3092810,"end":3093010,"confidence":0.9980469,"speaker":"C"},{"text":"at","start":3093010,"end":3093130,"confidence":1,"speaker":"C"},{"text":"that","start":3093130,"end":3093330,"confidence":0.99560547,"speaker":"C"},{"text":"point.","start":3093330,"end":3093690,"confidence":0.9980469,"speaker":"C"},{"text":"Okay.","start":3094410,"end":3094920,"confidence":0.92496747,"speaker":"A"},{"text":"When","start":3095630,"end":3095750,"confidence":0.71191406,"speaker":"A"},{"text":"you","start":3095750,"end":3095910,"confidence":0.93408203,"speaker":"A"},{"text":"create","start":3095910,"end":3096070,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":3096070,"end":3096230,"confidence":0.9951172,"speaker":"A"},{"text":"client,","start":3096230,"end":3096670,"confidence":0.9995117,"speaker":"A"},{"text":"so","start":3097630,"end":3097910,"confidence":0.9794922,"speaker":"A"},{"text":"underneath","start":3097910,"end":3098310,"confidence":0.9996745,"speaker":"A"},{"text":"the","start":3098310,"end":3098470,"confidence":0.9995117,"speaker":"A"},{"text":"client","start":3098470,"end":3098910,"confidence":1,"speaker":"A"},{"text":"you","start":3102350,"end":3102630,"confidence":0.99902344,"speaker":"A"},{"text":"have","start":3102630,"end":3102910,"confidence":1,"speaker":"A"},{"text":"what's","start":3102910,"end":3103230,"confidence":0.99934894,"speaker":"A"},{"text":"called","start":3103230,"end":3103350,"confidence":1,"speaker":"A"},{"text":"a","start":3103350,"end":3103510,"confidence":0.7114258,"speaker":"A"},{"text":"client","start":3103510,"end":3103790,"confidence":0.81811523,"speaker":"A"},{"text":"transport.","start":3103790,"end":3104430,"confidence":0.9987793,"speaker":"A"},{"text":"This","start":3104670,"end":3104950,"confidence":0.8666992,"speaker":"A"},{"text":"is","start":3104950,"end":3105230,"confidence":0.99902344,"speaker":"A"},{"text":"so","start":3105630,"end":3105910,"confidence":0.9921875,"speaker":"A"},{"text":"underneath","start":3105910,"end":3106430,"confidence":0.90999347,"speaker":"A"},{"text":"this","start":3106670,"end":3106990,"confidence":0.99902344,"speaker":"A"},{"text":"client,","start":3106990,"end":3107310,"confidence":0.9941406,"speaker":"A"},{"text":"this","start":3107310,"end":3107510,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":3107510,"end":3107630,"confidence":0.9995117,"speaker":"A"},{"text":"an","start":3107630,"end":3107750,"confidence":0.99902344,"speaker":"A"},{"text":"abstraction","start":3107750,"end":3108350,"confidence":0.99975586,"speaker":"A"},{"text":"layer","start":3108350,"end":3108750,"confidence":0.9995117,"speaker":"A"},{"text":"above.","start":3108750,"end":3109150,"confidence":0.8647461,"speaker":"A"},{"text":"So","start":3109870,"end":3110190,"confidence":0.58496094,"speaker":"A"},{"text":"this","start":3110190,"end":3110390,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3110390,"end":3110550,"confidence":0.99902344,"speaker":"A"},{"text":"not","start":3110550,"end":3110829,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":3110829,"end":3111109,"confidence":0.9995117,"speaker":"A"},{"text":"right","start":3111109,"end":3111270,"confidence":0.99609375,"speaker":"A"},{"text":"one.","start":3111270,"end":3111550,"confidence":0.98339844,"speaker":"A"},{"text":"Where's","start":3112190,"end":3112630,"confidence":0.98323566,"speaker":"A"},{"text":"the","start":3112630,"end":3112790,"confidence":1,"speaker":"A"},{"text":"public","start":3112790,"end":3113030,"confidence":0.9995117,"speaker":"A"},{"text":"one?","start":3113030,"end":3113390,"confidence":0.9916992,"speaker":"A"},{"text":"But","start":3120680,"end":3120800,"confidence":0.99560547,"speaker":"A"},{"text":"anyway,","start":3120800,"end":3121160,"confidence":0.9995117,"speaker":"A"},{"text":"there","start":3121160,"end":3121400,"confidence":0.9980469,"speaker":"A"},{"text":"is","start":3121400,"end":3121720,"confidence":0.9995117,"speaker":"A"},{"text":"here","start":3125080,"end":3125440,"confidence":0.97509766,"speaker":"A"},{"text":"CloudKit","start":3125440,"end":3126040,"confidence":0.98950195,"speaker":"A"},{"text":"service","start":3126040,"end":3126360,"confidence":0.9975586,"speaker":"A"},{"text":"maybe.","start":3126360,"end":3126920,"confidence":0.9958496,"speaker":"A"},{"text":"Yeah,","start":3129560,"end":3129920,"confidence":0.87158203,"speaker":"A"},{"text":"here","start":3129920,"end":3130080,"confidence":0.99853516,"speaker":"A"},{"text":"we","start":3130080,"end":3130240,"confidence":1,"speaker":"A"},{"text":"go.","start":3130240,"end":3130520,"confidence":1,"speaker":"A"},{"text":"So","start":3131320,"end":3131560,"confidence":0.9921875,"speaker":"A"},{"text":"the","start":3131560,"end":3131640,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":3131640,"end":3132280,"confidence":0.9147949,"speaker":"A"},{"text":"service","start":3132440,"end":3132840,"confidence":0.99609375,"speaker":"A"},{"text":"has","start":3133320,"end":3133640,"confidence":1,"speaker":"A"},{"text":"a","start":3133640,"end":3133840,"confidence":0.9995117,"speaker":"A"},{"text":"client","start":3133840,"end":3134360,"confidence":0.99975586,"speaker":"A"},{"text":"and","start":3135320,"end":3135640,"confidence":0.984375,"speaker":"A"},{"text":"part","start":3135640,"end":3135840,"confidence":1,"speaker":"A"},{"text":"of","start":3135840,"end":3136000,"confidence":1,"speaker":"A"},{"text":"the","start":3136000,"end":3136160,"confidence":1,"speaker":"A"},{"text":"client","start":3136160,"end":3136600,"confidence":0.99975586,"speaker":"A"},{"text":"is","start":3136920,"end":3137240,"confidence":0.99658203,"speaker":"A"},{"text":"being","start":3137240,"end":3137560,"confidence":0.9995117,"speaker":"A"},{"text":"able","start":3137560,"end":3137960,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3139960,"end":3140360,"confidence":1,"speaker":"A"},{"text":"say","start":3140440,"end":3140760,"confidence":0.9951172,"speaker":"A"},{"text":"what","start":3140760,"end":3140960,"confidence":0.9975586,"speaker":"A"},{"text":"transport","start":3140960,"end":3141520,"confidence":0.99853516,"speaker":"A"},{"text":"you","start":3141520,"end":3141760,"confidence":0.99609375,"speaker":"A"},{"text":"use","start":3141760,"end":3142040,"confidence":0.9970703,"speaker":"A"},{"text":"in","start":3142360,"end":3142640,"confidence":0.9169922,"speaker":"A"},{"text":"Open","start":3142640,"end":3142840,"confidence":0.9995117,"speaker":"A"},{"text":"API.","start":3142840,"end":3143560,"confidence":0.7491455,"speaker":"A"},{"text":"And","start":3144760,"end":3145160,"confidence":0.9868164,"speaker":"A"},{"text":"there's","start":3148850,"end":3149330,"confidence":0.84521484,"speaker":"A"},{"text":"two","start":3149330,"end":3149650,"confidence":0.99609375,"speaker":"A"},{"text":"transports","start":3149970,"end":3150730,"confidence":0.9951172,"speaker":"A"},{"text":"available","start":3150730,"end":3151010,"confidence":0.9995117,"speaker":"A"},{"text":"right","start":3151010,"end":3151330,"confidence":0.9995117,"speaker":"A"},{"text":"now.","start":3151330,"end":3151650,"confidence":0.9970703,"speaker":"A"},{"text":"One","start":3152770,"end":3153170,"confidence":0.9663086,"speaker":"A"},{"text":"is,","start":3153330,"end":3153730,"confidence":0.9975586,"speaker":"A"},{"text":"one","start":3156850,"end":3157170,"confidence":0.9892578,"speaker":"A"},{"text":"is","start":3157170,"end":3157490,"confidence":0.99853516,"speaker":"A"},{"text":"your","start":3157490,"end":3157810,"confidence":0.99658203,"speaker":"A"},{"text":"regular","start":3157810,"end":3158210,"confidence":1,"speaker":"A"},{"text":"URL","start":3158210,"end":3158770,"confidence":0.9992676,"speaker":"A"},{"text":"session","start":3158770,"end":3159130,"confidence":0.99934894,"speaker":"A"},{"text":"for","start":3159130,"end":3159290,"confidence":0.99853516,"speaker":"A"},{"text":"clients,","start":3159290,"end":3159730,"confidence":0.78100586,"speaker":"A"},{"text":"which.","start":3159890,"end":3160210,"confidence":0.99853516,"speaker":"A"},{"text":"That","start":3160210,"end":3160410,"confidence":0.9916992,"speaker":"A"},{"text":"makes","start":3160410,"end":3160610,"confidence":0.9951172,"speaker":"A"},{"text":"sense.","start":3160610,"end":3160930,"confidence":0.9995117,"speaker":"A"},{"text":"Right.","start":3160930,"end":3161250,"confidence":0.9897461,"speaker":"A"},{"text":"And","start":3161570,"end":3161890,"confidence":0.9921875,"speaker":"A"},{"text":"then","start":3161890,"end":3162089,"confidence":0.9892578,"speaker":"A"},{"text":"there's","start":3162089,"end":3162410,"confidence":0.9840495,"speaker":"A"},{"text":"the","start":3162410,"end":3162570,"confidence":0.9584961,"speaker":"A"},{"text":"Async","start":3162570,"end":3163170,"confidence":0.9949951,"speaker":"A"},{"text":"HTTP","start":3163170,"end":3163810,"confidence":0.9881592,"speaker":"A"},{"text":"client","start":3163810,"end":3164170,"confidence":0.9968262,"speaker":"A"},{"text":"which","start":3164170,"end":3164410,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3164410,"end":3164690,"confidence":0.9995117,"speaker":"A"},{"text":"typically","start":3164690,"end":3165090,"confidence":0.99975586,"speaker":"A"},{"text":"used","start":3165090,"end":3165410,"confidence":0.99658203,"speaker":"A"},{"text":"like","start":3165570,"end":3165850,"confidence":0.9838867,"speaker":"A"},{"text":"Swift","start":3165850,"end":3166130,"confidence":0.89575195,"speaker":"A"},{"text":"NEO","start":3166130,"end":3166530,"confidence":0.94506836,"speaker":"A"},{"text":"based","start":3166530,"end":3166850,"confidence":0.9980469,"speaker":"A"},{"text":"for","start":3167170,"end":3167490,"confidence":0.99560547,"speaker":"A"},{"text":"servers.","start":3167490,"end":3167970,"confidence":0.90649414,"speaker":"A"},{"text":"The","start":3169330,"end":3169610,"confidence":0.99853516,"speaker":"A"},{"text":"thing","start":3169610,"end":3169770,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3169770,"end":3169970,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":3169970,"end":3170170,"confidence":0.52441406,"speaker":"A"},{"text":"neither","start":3170170,"end":3170410,"confidence":0.99902344,"speaker":"A"},{"text":"of","start":3170410,"end":3170530,"confidence":0.9916992,"speaker":"A"},{"text":"those","start":3170530,"end":3170770,"confidence":0.9980469,"speaker":"A"},{"text":"are","start":3170930,"end":3171250,"confidence":0.99902344,"speaker":"A"},{"text":"available","start":3171250,"end":3171570,"confidence":0.99365234,"speaker":"A"},{"text":"in","start":3171730,"end":3172130,"confidence":0.9638672,"speaker":"A"},{"text":"wasp.","start":3172610,"end":3173170,"confidence":0.58813477,"speaker":"A"},{"text":"Do","start":3174290,"end":3174530,"confidence":0.6435547,"speaker":"A"},{"text":"you","start":3174530,"end":3174610,"confidence":0.99853516,"speaker":"A"},{"text":"know","start":3174610,"end":3174690,"confidence":0.9995117,"speaker":"A"},{"text":"what","start":3174690,"end":3174810,"confidence":0.9980469,"speaker":"A"},{"text":"WASM","start":3174810,"end":3175210,"confidence":0.78027344,"speaker":"A"},{"text":"is?","start":3175210,"end":3175490,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3176050,"end":3176290,"confidence":0.99902344,"speaker":"C"},{"text":"have","start":3176290,"end":3176410,"confidence":0.9995117,"speaker":"C"},{"text":"no","start":3176410,"end":3176570,"confidence":1,"speaker":"C"},{"text":"experience","start":3176570,"end":3176850,"confidence":1,"speaker":"C"},{"text":"with","start":3176850,"end":3177130,"confidence":0.9995117,"speaker":"C"},{"text":"it,","start":3177130,"end":3177290,"confidence":0.99853516,"speaker":"C"},{"text":"but","start":3177290,"end":3177450,"confidence":0.8720703,"speaker":"C"},{"text":"yes.","start":3177450,"end":3177810,"confidence":0.9963379,"speaker":"C"},{"text":"Okay.","start":3178850,"end":3179410,"confidence":0.9892578,"speaker":"A"},{"text":"It's.","start":3179490,"end":3179850,"confidence":0.96240234,"speaker":"A"},{"text":"It's","start":3179850,"end":3180290,"confidence":0.98811847,"speaker":"A"},{"text":"the","start":3180290,"end":3180570,"confidence":1,"speaker":"A"},{"text":"web","start":3180570,"end":3180810,"confidence":1,"speaker":"A"},{"text":"browser.","start":3180810,"end":3181210,"confidence":0.99869794,"speaker":"A"},{"text":"Right.","start":3181210,"end":3181490,"confidence":0.99853516,"speaker":"A"},{"text":"So.","start":3181890,"end":3182290,"confidence":0.98876953,"speaker":"A"},{"text":"So","start":3182690,"end":3182970,"confidence":0.9975586,"speaker":"A"},{"text":"you","start":3182970,"end":3183130,"confidence":1,"speaker":"A"},{"text":"really","start":3183130,"end":3183290,"confidence":1,"speaker":"A"},{"text":"can't","start":3183290,"end":3183490,"confidence":0.9998372,"speaker":"A"},{"text":"use","start":3183490,"end":3183690,"confidence":0.9995117,"speaker":"A"},{"text":"Miskit","start":3183690,"end":3184370,"confidence":0.95788574,"speaker":"A"},{"text":"in.","start":3184450,"end":3184850,"confidence":0.921875,"speaker":"A"},{"text":"In","start":3186450,"end":3186730,"confidence":0.99609375,"speaker":"A"},{"text":"the.","start":3186730,"end":3186930,"confidence":0.99609375,"speaker":"A"},{"text":"In","start":3186930,"end":3187170,"confidence":0.99658203,"speaker":"A"},{"text":"WASM","start":3187170,"end":3187690,"confidence":0.7368164,"speaker":"A"},{"text":"yet","start":3187690,"end":3187890,"confidence":0.85009766,"speaker":"A"},{"text":"because","start":3187890,"end":3188090,"confidence":1,"speaker":"A"},{"text":"there","start":3188090,"end":3188250,"confidence":1,"speaker":"A"},{"text":"is","start":3188250,"end":3188450,"confidence":0.9975586,"speaker":"A"},{"text":"no","start":3188450,"end":3188649,"confidence":0.9995117,"speaker":"A"},{"text":"transport.","start":3188649,"end":3189170,"confidence":0.998291,"speaker":"A"},{"text":"Now","start":3189170,"end":3189450,"confidence":0.9995117,"speaker":"A"},{"text":"having","start":3189450,"end":3189650,"confidence":1,"speaker":"A"},{"text":"said","start":3189650,"end":3189890,"confidence":1,"speaker":"A"},{"text":"that,","start":3189890,"end":3190210,"confidence":1,"speaker":"A"},{"text":"why","start":3190530,"end":3190850,"confidence":0.99902344,"speaker":"A"},{"text":"on","start":3190850,"end":3191050,"confidence":0.99902344,"speaker":"A"},{"text":"earth","start":3191050,"end":3191290,"confidence":1,"speaker":"A"},{"text":"would","start":3191290,"end":3191450,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":3191450,"end":3191730,"confidence":0.9995117,"speaker":"A"},{"text":"use.","start":3192050,"end":3192450,"confidence":0.99658203,"speaker":"A"},{"text":"Awesome.","start":3193090,"end":3193810,"confidence":0.7972819,"speaker":"A"},{"text":"Why","start":3194050,"end":3194330,"confidence":0.7753906,"speaker":"A"},{"text":"would","start":3194330,"end":3194450,"confidence":0.9667969,"speaker":"A"},{"text":"you","start":3194450,"end":3194530,"confidence":0.8652344,"speaker":"A"},{"text":"use","start":3194530,"end":3194650,"confidence":0.9169922,"speaker":"A"},{"text":"Miskit","start":3194650,"end":3195130,"confidence":0.9088135,"speaker":"A"},{"text":"in","start":3195130,"end":3195250,"confidence":0.99609375,"speaker":"A"},{"text":"the","start":3195250,"end":3195330,"confidence":0.9995117,"speaker":"A"},{"text":"browser?","start":3195330,"end":3195690,"confidence":1,"speaker":"A"},{"text":"Why","start":3195690,"end":3195930,"confidence":0.9995117,"speaker":"A"},{"text":"not","start":3195930,"end":3196090,"confidence":0.9995117,"speaker":"A"},{"text":"just","start":3196090,"end":3196250,"confidence":0.9995117,"speaker":"A"},{"text":"use","start":3196250,"end":3196450,"confidence":0.9995117,"speaker":"A"},{"text":"CloudKit","start":3196450,"end":3196970,"confidence":0.99780273,"speaker":"A"},{"text":"js?","start":3196970,"end":3197410,"confidence":0.8005371,"speaker":"A"},{"text":"So","start":3198380,"end":3198620,"confidence":0.98828125,"speaker":"A"},{"text":"that's","start":3199660,"end":3200100,"confidence":0.9996745,"speaker":"A"},{"text":"essentially,","start":3200100,"end":3200700,"confidence":0.9996338,"speaker":"A"},{"text":"you","start":3201580,"end":3201820,"confidence":0.765625,"speaker":"A"},{"text":"know,","start":3201820,"end":3202060,"confidence":0.77685547,"speaker":"A"},{"text":"What","start":3209260,"end":3209540,"confidence":0.99902344,"speaker":"A"},{"text":"other","start":3209540,"end":3209780,"confidence":0.9975586,"speaker":"A"},{"text":"questions","start":3209780,"end":3210340,"confidence":0.99975586,"speaker":"A"},{"text":"do","start":3210340,"end":3210500,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":3210500,"end":3210660,"confidence":1,"speaker":"A"},{"text":"have?","start":3210660,"end":3210940,"confidence":1,"speaker":"A"},{"text":"My","start":3215660,"end":3216060,"confidence":0.96240234,"speaker":"C"},{"text":"brain","start":3216300,"end":3216780,"confidence":0.99975586,"speaker":"C"},{"text":"is","start":3216780,"end":3217020,"confidence":0.9995117,"speaker":"C"},{"text":"mushy","start":3217020,"end":3217460,"confidence":0.9998372,"speaker":"C"},{"text":"right","start":3217460,"end":3217620,"confidence":0.9995117,"speaker":"C"},{"text":"now,","start":3217620,"end":3217900,"confidence":1,"speaker":"C"},{"text":"so","start":3217900,"end":3218300,"confidence":0.9770508,"speaker":"C"},{"text":"because","start":3221020,"end":3221340,"confidence":0.9970703,"speaker":"A"},{"text":"of","start":3221340,"end":3221540,"confidence":0.99609375,"speaker":"A"},{"text":"my","start":3221540,"end":3221700,"confidence":0.99853516,"speaker":"A"},{"text":"presentation","start":3221700,"end":3222300,"confidence":0.99975586,"speaker":"A"},{"text":"or","start":3222300,"end":3222540,"confidence":0.9902344,"speaker":"A"},{"text":"because","start":3222540,"end":3222860,"confidence":0.99853516,"speaker":"A"},{"text":"other","start":3223020,"end":3223380,"confidence":0.99902344,"speaker":"A"},{"text":"things,","start":3223380,"end":3223740,"confidence":0.9946289,"speaker":"C"},{"text":"I","start":3224570,"end":3224730,"confidence":0.98876953,"speaker":"C"},{"text":"got","start":3224730,"end":3224930,"confidence":0.9995117,"speaker":"C"},{"text":"two","start":3224930,"end":3225090,"confidence":0.9995117,"speaker":"C"},{"text":"hours","start":3225090,"end":3225290,"confidence":1,"speaker":"C"},{"text":"of","start":3225290,"end":3225450,"confidence":0.9873047,"speaker":"C"},{"text":"sleep.","start":3225450,"end":3225850,"confidence":0.9555664,"speaker":"C"},{"text":"Oh,","start":3226650,"end":3226970,"confidence":0.7734375,"speaker":"A"},{"text":"I'm","start":3226970,"end":3227130,"confidence":0.9970703,"speaker":"A"},{"text":"so","start":3227130,"end":3227290,"confidence":0.99365234,"speaker":"A"},{"text":"sorry.","start":3227290,"end":3227690,"confidence":0.9998372,"speaker":"A"},{"text":"So","start":3228170,"end":3228570,"confidence":0.95214844,"speaker":"C"},{"text":"I'm","start":3229770,"end":3230170,"confidence":0.97526044,"speaker":"C"},{"text":"following","start":3230170,"end":3230450,"confidence":0.99853516,"speaker":"C"},{"text":"as","start":3230450,"end":3230690,"confidence":0.9995117,"speaker":"C"},{"text":"best","start":3230690,"end":3230850,"confidence":0.9980469,"speaker":"C"},{"text":"as","start":3230850,"end":3231010,"confidence":0.9941406,"speaker":"C"},{"text":"I","start":3231010,"end":3231170,"confidence":0.9995117,"speaker":"C"},{"text":"can.","start":3231170,"end":3231450,"confidence":0.99902344,"speaker":"C"},{"text":"Snuggling.","start":3234330,"end":3235050,"confidence":0.87927246,"speaker":"A"},{"text":"Yeah,","start":3237050,"end":3237410,"confidence":0.96761066,"speaker":"A"},{"text":"the","start":3237410,"end":3237570,"confidence":0.99609375,"speaker":"A"},{"text":"intro","start":3237570,"end":3238010,"confidence":0.99975586,"speaker":"A"},{"text":"was","start":3238090,"end":3238410,"confidence":0.99853516,"speaker":"A"},{"text":"basically","start":3238410,"end":3238890,"confidence":0.9995117,"speaker":"A"},{"text":"how","start":3239290,"end":3239610,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":3239610,"end":3239930,"confidence":0.9946289,"speaker":"A"},{"text":"originally","start":3240490,"end":3241010,"confidence":0.9998372,"speaker":"A"},{"text":"built","start":3241010,"end":3241250,"confidence":0.992513,"speaker":"A"},{"text":"it","start":3241250,"end":3241410,"confidence":0.9814453,"speaker":"A"},{"text":"for","start":3241410,"end":3241570,"confidence":0.9995117,"speaker":"A"},{"text":"hard","start":3241570,"end":3241730,"confidence":0.4362793,"speaker":"A"},{"text":"Twitch","start":3241730,"end":3242050,"confidence":0.9111328,"speaker":"A"},{"text":"in","start":3242050,"end":3242210,"confidence":0.99316406,"speaker":"A"},{"text":"2020","start":3242210,"end":3242810,"confidence":0.99854,"speaker":"A"},{"text":"for","start":3243210,"end":3243490,"confidence":0.94628906,"speaker":"A"},{"text":"a","start":3243490,"end":3243650,"confidence":0.7871094,"speaker":"A"},{"text":"private","start":3243650,"end":3243890,"confidence":1,"speaker":"A"},{"text":"database","start":3243890,"end":3244570,"confidence":0.99576825,"speaker":"A"},{"text":"login","start":3244730,"end":3245450,"confidence":0.9367676,"speaker":"A"},{"text":"for","start":3245930,"end":3246210,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":3246210,"end":3246370,"confidence":0.9980469,"speaker":"A"},{"text":"Apple","start":3246370,"end":3246650,"confidence":0.99975586,"speaker":"A"},{"text":"Watch","start":3246650,"end":3246890,"confidence":0.8803711,"speaker":"A"},{"text":"because","start":3246890,"end":3247170,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3247170,"end":3247290,"confidence":0.9975586,"speaker":"A"},{"text":"don't","start":3247290,"end":3247450,"confidence":0.99658203,"speaker":"A"},{"text":"want","start":3247450,"end":3247530,"confidence":0.8720703,"speaker":"A"},{"text":"to","start":3247530,"end":3247610,"confidence":0.9980469,"speaker":"A"},{"text":"have","start":3247610,"end":3247690,"confidence":0.9995117,"speaker":"A"},{"text":"a","start":3247690,"end":3247810,"confidence":0.99853516,"speaker":"A"},{"text":"login","start":3247810,"end":3248210,"confidence":0.99731445,"speaker":"A"},{"text":"screen.","start":3248210,"end":3248490,"confidence":0.99975586,"speaker":"A"},{"text":"And","start":3248490,"end":3248690,"confidence":0.98583984,"speaker":"A"},{"text":"so","start":3248690,"end":3248810,"confidence":0.99902344,"speaker":"A"},{"text":"basically","start":3248810,"end":3249210,"confidence":0.9995117,"speaker":"A"},{"text":"there's","start":3249210,"end":3249570,"confidence":0.99934894,"speaker":"A"},{"text":"a","start":3249570,"end":3249690,"confidence":0.99853516,"speaker":"A"},{"text":"way","start":3249690,"end":3249810,"confidence":0.99853516,"speaker":"A"},{"text":"in","start":3249810,"end":3249930,"confidence":0.9980469,"speaker":"A"},{"text":"the","start":3249930,"end":3250010,"confidence":0.99902344,"speaker":"A"},{"text":"web","start":3250010,"end":3250170,"confidence":0.9995117,"speaker":"A"},{"text":"browser","start":3250170,"end":3250450,"confidence":1,"speaker":"A"},{"text":"to","start":3250450,"end":3250610,"confidence":0.99902344,"speaker":"A"},{"text":"link","start":3250610,"end":3250810,"confidence":0.99975586,"speaker":"A"},{"text":"your","start":3250810,"end":3250970,"confidence":0.99902344,"speaker":"A"},{"text":"Apple","start":3250970,"end":3251290,"confidence":0.9333496,"speaker":"A"},{"text":"Watch","start":3251290,"end":3251610,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3251770,"end":3252050,"confidence":0.9975586,"speaker":"A"},{"text":"your","start":3252050,"end":3252210,"confidence":0.99902344,"speaker":"A"},{"text":"account","start":3252210,"end":3252490,"confidence":1,"speaker":"A"},{"text":"and","start":3252490,"end":3252770,"confidence":0.99316406,"speaker":"A"},{"text":"then","start":3252770,"end":3252970,"confidence":0.8930664,"speaker":"A"},{"text":"from","start":3252970,"end":3253130,"confidence":1,"speaker":"A"},{"text":"there","start":3253130,"end":3253290,"confidence":1,"speaker":"A"},{"text":"you","start":3253290,"end":3253450,"confidence":1,"speaker":"A"},{"text":"don't","start":3253450,"end":3253610,"confidence":1,"speaker":"A"},{"text":"need","start":3253610,"end":3253730,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3253730,"end":3253850,"confidence":0.95947266,"speaker":"A"},{"text":"authenticate","start":3253850,"end":3254370,"confidence":0.99975586,"speaker":"A"},{"text":"anymore.","start":3254370,"end":3254890,"confidence":0.991862,"speaker":"A"},{"text":"Nice.","start":3255280,"end":3255600,"confidence":0.94921875,"speaker":"A"},{"text":"I","start":3255760,"end":3256000,"confidence":0.9970703,"speaker":"A"},{"text":"built","start":3256000,"end":3256280,"confidence":0.8284505,"speaker":"A"},{"text":"that","start":3256280,"end":3256440,"confidence":0.9692383,"speaker":"A"},{"text":"all","start":3256440,"end":3256600,"confidence":0.99609375,"speaker":"A"},{"text":"from","start":3256600,"end":3256800,"confidence":1,"speaker":"A"},{"text":"hand","start":3256800,"end":3257120,"confidence":0.9951172,"speaker":"A"},{"text":"and","start":3258400,"end":3258680,"confidence":0.73095703,"speaker":"A"},{"text":"then","start":3258680,"end":3258960,"confidence":0.9941406,"speaker":"A"},{"text":"in","start":3259200,"end":3259520,"confidence":0.9970703,"speaker":"A"},{"text":"23","start":3259520,"end":3260040,"confidence":0.9939,"speaker":"A"},{"text":"they","start":3260040,"end":3260280,"confidence":0.9995117,"speaker":"A"},{"text":"came","start":3260280,"end":3260440,"confidence":0.9995117,"speaker":"A"},{"text":"out","start":3260440,"end":3260560,"confidence":0.94921875,"speaker":"A"},{"text":"with","start":3260560,"end":3260680,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3260680,"end":3260800,"confidence":0.93652344,"speaker":"A"},{"text":"Open","start":3260800,"end":3261000,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":3261000,"end":3261520,"confidence":0.9807129,"speaker":"A"},{"text":"generator","start":3261520,"end":3262160,"confidence":0.9995117,"speaker":"A"},{"text":"which","start":3262640,"end":3263000,"confidence":0.99609375,"speaker":"A"},{"text":"was","start":3263000,"end":3263280,"confidence":0.64746094,"speaker":"A"},{"text":"like,","start":3263280,"end":3263480,"confidence":0.97558594,"speaker":"A"},{"text":"oh","start":3263480,"end":3263760,"confidence":0.91674805,"speaker":"A"},{"text":"wait,","start":3263760,"end":3264160,"confidence":0.9980469,"speaker":"A"},{"text":"what","start":3264160,"end":3264440,"confidence":0.99121094,"speaker":"A"},{"text":"if","start":3264440,"end":3264720,"confidence":0.99853516,"speaker":"A"},{"text":"I","start":3264800,"end":3265040,"confidence":0.9980469,"speaker":"A"},{"text":"can","start":3265040,"end":3265160,"confidence":0.99658203,"speaker":"A"},{"text":"create","start":3265160,"end":3265320,"confidence":0.99902344,"speaker":"A"},{"text":"an","start":3265320,"end":3265480,"confidence":0.96777344,"speaker":"A"},{"text":"open","start":3265480,"end":3265720,"confidence":0.9995117,"speaker":"A"},{"text":"API","start":3265720,"end":3266320,"confidence":0.98046875,"speaker":"A"},{"text":"file","start":3266800,"end":3267280,"confidence":0.98046875,"speaker":"A"},{"text":"out","start":3267520,"end":3267840,"confidence":0.99560547,"speaker":"A"},{"text":"of","start":3267840,"end":3268160,"confidence":0.99853516,"speaker":"A"},{"text":"Apple's","start":3268320,"end":3269040,"confidence":0.9937744,"speaker":"A"},{"text":"10","start":3269280,"end":3269600,"confidence":0.99951,"speaker":"A"},{"text":"year","start":3269600,"end":3269800,"confidence":0.9995117,"speaker":"A"},{"text":"old","start":3269800,"end":3270000,"confidence":0.99902344,"speaker":"A"},{"text":"documentation?","start":3270000,"end":3270800,"confidence":0.9923828,"speaker":"A"},{"text":"That'd","start":3273120,"end":3273520,"confidence":0.8873698,"speaker":"A"},{"text":"be","start":3273520,"end":3273640,"confidence":1,"speaker":"A"},{"text":"a","start":3273640,"end":3273760,"confidence":0.99902344,"speaker":"A"},{"text":"lot","start":3273760,"end":3273840,"confidence":1,"speaker":"A"},{"text":"of","start":3273840,"end":3273960,"confidence":0.9975586,"speaker":"A"},{"text":"work,","start":3273960,"end":3274160,"confidence":1,"speaker":"A"},{"text":"but","start":3274160,"end":3274400,"confidence":0.6777344,"speaker":"A"},{"text":"I","start":3274400,"end":3274600,"confidence":0.9995117,"speaker":"A"},{"text":"could","start":3274600,"end":3274760,"confidence":0.98876953,"speaker":"A"},{"text":"do","start":3274760,"end":3274920,"confidence":0.9995117,"speaker":"A"},{"text":"it.","start":3274920,"end":3275200,"confidence":0.9995117,"speaker":"A"},{"text":"And","start":3275520,"end":3275920,"confidence":0.8173828,"speaker":"A"},{"text":"I","start":3276000,"end":3276280,"confidence":0.99902344,"speaker":"A"},{"text":"don't","start":3276280,"end":3276480,"confidence":0.9949544,"speaker":"A"},{"text":"know","start":3276480,"end":3276560,"confidence":0.99902344,"speaker":"A"},{"text":"if","start":3276560,"end":3276640,"confidence":1,"speaker":"A"},{"text":"you","start":3276640,"end":3276760,"confidence":0.9995117,"speaker":"A"},{"text":"heard,","start":3276760,"end":3277120,"confidence":0.99902344,"speaker":"A"},{"text":"but","start":3277600,"end":3278000,"confidence":0.9921875,"speaker":"A"},{"text":"there","start":3278960,"end":3279240,"confidence":0.9995117,"speaker":"A"},{"text":"was","start":3279240,"end":3279400,"confidence":0.9589844,"speaker":"A"},{"text":"this","start":3279400,"end":3279560,"confidence":0.9746094,"speaker":"A"},{"text":"thing","start":3279560,"end":3279720,"confidence":0.9995117,"speaker":"A"},{"text":"that","start":3279720,"end":3279840,"confidence":0.99902344,"speaker":"A"},{"text":"came","start":3279840,"end":3279960,"confidence":0.99853516,"speaker":"A"},{"text":"out","start":3279960,"end":3280240,"confidence":0.9980469,"speaker":"A"},{"text":"a","start":3280240,"end":3280480,"confidence":0.99853516,"speaker":"A"},{"text":"couple","start":3280480,"end":3280720,"confidence":0.9992676,"speaker":"A"},{"text":"years","start":3280720,"end":3280920,"confidence":0.9995117,"speaker":"A"},{"text":"ago","start":3280920,"end":3281200,"confidence":0.9980469,"speaker":"A"},{"text":"called","start":3281780,"end":3282020,"confidence":0.99609375,"speaker":"A"},{"text":"AI","start":3282580,"end":3283220,"confidence":0.95092773,"speaker":"A"},{"text":"and","start":3283940,"end":3284340,"confidence":0.9873047,"speaker":"A"},{"text":"it's","start":3284980,"end":3285340,"confidence":0.9996745,"speaker":"A"},{"text":"really","start":3285340,"end":3285500,"confidence":0.9995117,"speaker":"A"},{"text":"good","start":3285500,"end":3285700,"confidence":0.9995117,"speaker":"A"},{"text":"at","start":3285700,"end":3285900,"confidence":0.98095703,"speaker":"A"},{"text":"creating","start":3285900,"end":3286260,"confidence":0.9995117,"speaker":"A"},{"text":"documentation","start":3286260,"end":3286940,"confidence":0.99990237,"speaker":"A"},{"text":"for","start":3286940,"end":3287180,"confidence":1,"speaker":"A"},{"text":"your","start":3287180,"end":3287340,"confidence":0.9995117,"speaker":"A"},{"text":"code,","start":3287340,"end":3287660,"confidence":0.94222003,"speaker":"A"},{"text":"but","start":3287660,"end":3287900,"confidence":0.9975586,"speaker":"A"},{"text":"it's","start":3287900,"end":3288100,"confidence":0.9998372,"speaker":"A"},{"text":"also","start":3288100,"end":3288260,"confidence":0.9995117,"speaker":"A"},{"text":"really","start":3288260,"end":3288500,"confidence":0.5620117,"speaker":"A"},{"text":"good","start":3288500,"end":3288700,"confidence":0.9995117,"speaker":"A"},{"text":"at","start":3288700,"end":3288860,"confidence":0.9995117,"speaker":"A"},{"text":"creating","start":3288860,"end":3289140,"confidence":0.96777344,"speaker":"A"},{"text":"code","start":3289140,"end":3289420,"confidence":0.9996745,"speaker":"A"},{"text":"for","start":3289420,"end":3289620,"confidence":0.9995117,"speaker":"A"},{"text":"your","start":3289620,"end":3289820,"confidence":0.9995117,"speaker":"A"},{"text":"documentation.","start":3289820,"end":3290500,"confidence":0.99902344,"speaker":"A"},{"text":"And","start":3291300,"end":3291580,"confidence":0.8925781,"speaker":"A"},{"text":"so","start":3291580,"end":3291700,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":3291700,"end":3291820,"confidence":0.9975586,"speaker":"A"},{"text":"was","start":3291820,"end":3292020,"confidence":0.9995117,"speaker":"A"},{"text":"like,","start":3292020,"end":3292340,"confidence":0.99658203,"speaker":"A"},{"text":"oh","start":3292500,"end":3292980,"confidence":0.9580078,"speaker":"A"},{"text":"yeah,","start":3293460,"end":3293940,"confidence":0.9975586,"speaker":"A"},{"text":"this","start":3293940,"end":3294220,"confidence":0.9951172,"speaker":"A"},{"text":"is","start":3294220,"end":3294380,"confidence":0.99853516,"speaker":"A"},{"text":"great.","start":3294380,"end":3294660,"confidence":0.9980469,"speaker":"A"},{"text":"Like","start":3295060,"end":3295460,"confidence":0.9238281,"speaker":"A"},{"text":"I","start":3295460,"end":3295740,"confidence":0.9707031,"speaker":"A"},{"text":"can","start":3295740,"end":3295900,"confidence":0.99658203,"speaker":"A"},{"text":"just,","start":3295900,"end":3296180,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3296740,"end":3296980,"confidence":0.97753906,"speaker":"A"},{"text":"can","start":3296980,"end":3297140,"confidence":0.7270508,"speaker":"A"},{"text":"just","start":3297140,"end":3297420,"confidence":0.9995117,"speaker":"A"},{"text":"Feed","start":3297420,"end":3297739,"confidence":0.9968262,"speaker":"A"},{"text":"it","start":3297739,"end":3297900,"confidence":0.8671875,"speaker":"A"},{"text":"the","start":3297900,"end":3298060,"confidence":0.99853516,"speaker":"A"},{"text":"documentation","start":3298060,"end":3298740,"confidence":0.99921876,"speaker":"A"},{"text":"and","start":3298980,"end":3299380,"confidence":0.9238281,"speaker":"A"},{"text":"go","start":3301140,"end":3301420,"confidence":0.9970703,"speaker":"A"},{"text":"from","start":3301420,"end":3301620,"confidence":0.9995117,"speaker":"A"},{"text":"there.","start":3301620,"end":3301940,"confidence":0.9995117,"speaker":"A"},{"text":"And,","start":3302020,"end":3302340,"confidence":0.97998047,"speaker":"A"},{"text":"like,","start":3302340,"end":3302660,"confidence":0.9477539,"speaker":"A"},{"text":"basically,","start":3302820,"end":3303300,"confidence":0.99975586,"speaker":"A"},{"text":"I've","start":3303300,"end":3303540,"confidence":0.99072266,"speaker":"A"},{"text":"been","start":3303540,"end":3303660,"confidence":0.9902344,"speaker":"A"},{"text":"going","start":3303660,"end":3303860,"confidence":0.9995117,"speaker":"A"},{"text":"step","start":3303860,"end":3304060,"confidence":0.9995117,"speaker":"A"},{"text":"by","start":3304060,"end":3304260,"confidence":1,"speaker":"A"},{"text":"step","start":3304260,"end":3304580,"confidence":1,"speaker":"A"},{"text":"through.","start":3304740,"end":3305140,"confidence":0.98876953,"speaker":"A"},{"text":"Like","start":3305940,"end":3306260,"confidence":0.9980469,"speaker":"A"},{"text":"I","start":3306260,"end":3306460,"confidence":1,"speaker":"A"},{"text":"said,","start":3306460,"end":3306620,"confidence":1,"speaker":"A"},{"text":"if","start":3306620,"end":3306820,"confidence":0.6225586,"speaker":"A"},{"text":"you","start":3306820,"end":3306980,"confidence":1,"speaker":"A"},{"text":"looked","start":3306980,"end":3307220,"confidence":0.9802246,"speaker":"A"},{"text":"at","start":3307220,"end":3307340,"confidence":0.9995117,"speaker":"A"},{"text":"the","start":3307340,"end":3307620,"confidence":0.94140625,"speaker":"A"},{"text":"miskit","start":3307700,"end":3308500,"confidence":0.876709,"speaker":"A"},{"text":"repo,","start":3308780,"end":3309300,"confidence":0.99072266,"speaker":"A"},{"text":"like,","start":3309300,"end":3309580,"confidence":0.9838867,"speaker":"A"},{"text":"I'm","start":3309580,"end":3309820,"confidence":0.9995117,"speaker":"A"},{"text":"going","start":3309820,"end":3309940,"confidence":0.9995117,"speaker":"A"},{"text":"through","start":3309940,"end":3310140,"confidence":0.9995117,"speaker":"A"},{"text":"step","start":3310140,"end":3310340,"confidence":0.9946289,"speaker":"A"},{"text":"by","start":3310340,"end":3310500,"confidence":0.99902344,"speaker":"A"},{"text":"step","start":3310500,"end":3310660,"confidence":1,"speaker":"A"},{"text":"and","start":3310660,"end":3310820,"confidence":0.93896484,"speaker":"A"},{"text":"adding","start":3310820,"end":3311260,"confidence":0.998291,"speaker":"A"},{"text":"new","start":3311660,"end":3312060,"confidence":0.9995117,"speaker":"A"},{"text":"APIs","start":3312380,"end":3313100,"confidence":0.98168945,"speaker":"A"},{"text":"based","start":3314300,"end":3314620,"confidence":0.9995117,"speaker":"A"},{"text":"on","start":3314620,"end":3314780,"confidence":0.9995117,"speaker":"A"},{"text":"what's","start":3314780,"end":3315020,"confidence":0.9996745,"speaker":"A"},{"text":"available","start":3315020,"end":3315220,"confidence":1,"speaker":"A"},{"text":"in","start":3315220,"end":3315460,"confidence":0.95654297,"speaker":"A"},{"text":"the","start":3315460,"end":3315580,"confidence":0.99902344,"speaker":"A"},{"text":"documentation,","start":3315580,"end":3316300,"confidence":0.99677736,"speaker":"A"},{"text":"piece","start":3316700,"end":3317060,"confidence":0.9938151,"speaker":"A"},{"text":"by","start":3317060,"end":3317220,"confidence":0.9291992,"speaker":"A"},{"text":"piece.","start":3317220,"end":3317500,"confidence":0.99332684,"speaker":"A"},{"text":"And","start":3317500,"end":3317660,"confidence":0.99121094,"speaker":"A"},{"text":"I","start":3317660,"end":3317740,"confidence":0.9995117,"speaker":"A"},{"text":"would","start":3317740,"end":3317820,"confidence":1,"speaker":"A"},{"text":"say","start":3317820,"end":3317940,"confidence":1,"speaker":"A"},{"text":"at","start":3317940,"end":3318060,"confidence":0.9995117,"speaker":"A"},{"text":"this","start":3318060,"end":3318180,"confidence":1,"speaker":"A"},{"text":"point,","start":3318180,"end":3318340,"confidence":0.99902344,"speaker":"A"},{"text":"it's","start":3318340,"end":3318580,"confidence":0.9899089,"speaker":"A"},{"text":"like","start":3318580,"end":3318860,"confidence":0.9975586,"speaker":"A"},{"text":"most","start":3319340,"end":3319660,"confidence":1,"speaker":"A"},{"text":"of","start":3319660,"end":3319820,"confidence":0.99902344,"speaker":"A"},{"text":"the","start":3319820,"end":3320020,"confidence":0.99658203,"speaker":"A"},{"text":"really,","start":3320020,"end":3320380,"confidence":0.99658203,"speaker":"A"},{"text":"like","start":3320620,"end":3320940,"confidence":0.98876953,"speaker":"A"},{"text":"80%","start":3320940,"end":3321500,"confidence":0.96655,"speaker":"A"},{"text":"of","start":3321500,"end":3321780,"confidence":0.7285156,"speaker":"A"},{"text":"that","start":3321780,"end":3321940,"confidence":0.9941406,"speaker":"A"},{"text":"people","start":3321940,"end":3322140,"confidence":1,"speaker":"A"},{"text":"use","start":3322140,"end":3322420,"confidence":0.9995117,"speaker":"A"},{"text":"is","start":3322420,"end":3322660,"confidence":0.98876953,"speaker":"A"},{"text":"there.","start":3322660,"end":3322940,"confidence":0.9951172,"speaker":"A"},{"text":"There's","start":3322940,"end":3323340,"confidence":0.9998372,"speaker":"A"},{"text":"like,","start":3323340,"end":3323500,"confidence":0.99121094,"speaker":"A"},{"text":"stuff","start":3323500,"end":3323780,"confidence":0.9995117,"speaker":"A"},{"text":"like","start":3323780,"end":3323980,"confidence":0.99902344,"speaker":"A"},{"text":"subscriptions","start":3323980,"end":3324619,"confidence":0.99501956,"speaker":"A"},{"text":"and","start":3324619,"end":3324940,"confidence":0.99658203,"speaker":"A"},{"text":"zones","start":3324940,"end":3325300,"confidence":0.95703125,"speaker":"A"},{"text":"that","start":3325300,"end":3325660,"confidence":0.99316406,"speaker":"A"},{"text":"I'm","start":3325980,"end":3326340,"confidence":0.9868164,"speaker":"A"},{"text":"still","start":3326340,"end":3326500,"confidence":0.9975586,"speaker":"A"},{"text":"trying","start":3326500,"end":3326700,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3326700,"end":3326860,"confidence":0.9995117,"speaker":"A"},{"text":"figure","start":3326860,"end":3327140,"confidence":0.99975586,"speaker":"A"},{"text":"out,","start":3327140,"end":3327420,"confidence":0.99121094,"speaker":"A"},{"text":"but","start":3328460,"end":3328780,"confidence":0.9941406,"speaker":"A"},{"text":"it's.","start":3328780,"end":3329100,"confidence":0.9900716,"speaker":"A"},{"text":"It's","start":3329100,"end":3329340,"confidence":0.98746747,"speaker":"A"},{"text":"pretty","start":3329340,"end":3329540,"confidence":0.9991862,"speaker":"A"},{"text":"close","start":3329540,"end":3329740,"confidence":0.9995117,"speaker":"A"},{"text":"to","start":3329740,"end":3329980,"confidence":0.9975586,"speaker":"A"},{"text":"done","start":3329980,"end":3330260,"confidence":0.95410156,"speaker":"A"},{"text":"at","start":3330260,"end":3330460,"confidence":0.99902344,"speaker":"A"},{"text":"this","start":3330460,"end":3330620,"confidence":0.95751953,"speaker":"A"},{"text":"point.","start":3330620,"end":3330940,"confidence":0.66552734,"speaker":"A"},{"text":"Mm.","start":3331260,"end":3331900,"confidence":0.62402344,"speaker":"B"},{"text":"If","start":3335110,"end":3335230,"confidence":0.56103516,"speaker":"A"},{"text":"you","start":3335230,"end":3335350,"confidence":0.99902344,"speaker":"A"},{"text":"use","start":3335350,"end":3335510,"confidence":0.9975586,"speaker":"A"},{"text":"it.","start":3335510,"end":3335830,"confidence":0.5029297,"speaker":"A"},{"text":"Yeah,","start":3336230,"end":3336550,"confidence":0.9943034,"speaker":"C"},{"text":"it's","start":3336550,"end":3336630,"confidence":0.94905597,"speaker":"C"},{"text":"one","start":3336630,"end":3336750,"confidence":0.9902344,"speaker":"C"},{"text":"of","start":3336750,"end":3336870,"confidence":0.99853516,"speaker":"C"},{"text":"those.","start":3336870,"end":3337110,"confidence":0.9760742,"speaker":"C"},{"text":"Because","start":3337270,"end":3337630,"confidence":0.7348633,"speaker":"A"},{"text":"I.","start":3337630,"end":3337990,"confidence":0.86621094,"speaker":"A"},{"text":"Go","start":3338070,"end":3338350,"confidence":0.9902344,"speaker":"A"},{"text":"ahead.","start":3338350,"end":3338590,"confidence":0.9980469,"speaker":"A"},{"text":"Yeah.","start":3338590,"end":3338950,"confidence":0.99397784,"speaker":"C"},{"text":"I","start":3338950,"end":3339110,"confidence":0.49267578,"speaker":"C"},{"text":"was","start":3339110,"end":3339230,"confidence":0.9189453,"speaker":"C"},{"text":"gonna","start":3339230,"end":3339430,"confidence":0.83776855,"speaker":"C"},{"text":"say","start":3339430,"end":3339510,"confidence":1,"speaker":"C"},{"text":"it's","start":3339510,"end":3339670,"confidence":0.9998372,"speaker":"C"},{"text":"one","start":3339670,"end":3339750,"confidence":1,"speaker":"C"},{"text":"of","start":3339750,"end":3339830,"confidence":0.9995117,"speaker":"C"},{"text":"those","start":3339830,"end":3339950,"confidence":0.9995117,"speaker":"C"},{"text":"projects","start":3339950,"end":3340310,"confidence":0.99975586,"speaker":"C"},{"text":"that","start":3340310,"end":3340430,"confidence":1,"speaker":"C"},{"text":"makes","start":3340430,"end":3340590,"confidence":0.9995117,"speaker":"C"},{"text":"me","start":3340590,"end":3340750,"confidence":0.9995117,"speaker":"C"},{"text":"want","start":3340750,"end":3340910,"confidence":0.9604492,"speaker":"C"},{"text":"to","start":3340910,"end":3341070,"confidence":1,"speaker":"C"},{"text":"set","start":3341070,"end":3341230,"confidence":1,"speaker":"C"},{"text":"up","start":3341230,"end":3341390,"confidence":0.9995117,"speaker":"C"},{"text":"a.","start":3341390,"end":3341670,"confidence":0.96240234,"speaker":"C"},{"text":"Like","start":3342150,"end":3342470,"confidence":0.9941406,"speaker":"C"},{"text":"a","start":3342470,"end":3342750,"confidence":0.99902344,"speaker":"C"},{"text":"vapor","start":3342750,"end":3343310,"confidence":0.98551434,"speaker":"C"},{"text":"server","start":3343310,"end":3343630,"confidence":0.9995117,"speaker":"C"},{"text":"or","start":3343630,"end":3343790,"confidence":0.99853516,"speaker":"C"},{"text":"something","start":3343790,"end":3344030,"confidence":1,"speaker":"C"},{"text":"just","start":3344030,"end":3344270,"confidence":1,"speaker":"C"},{"text":"to","start":3344270,"end":3344390,"confidence":1,"speaker":"C"},{"text":"do","start":3344390,"end":3344510,"confidence":0.9995117,"speaker":"C"},{"text":"some","start":3344510,"end":3344670,"confidence":1,"speaker":"C"},{"text":"Swift","start":3344670,"end":3344990,"confidence":0.99975586,"speaker":"C"},{"text":"on","start":3344990,"end":3345110,"confidence":1,"speaker":"C"},{"text":"the","start":3345110,"end":3345230,"confidence":1,"speaker":"C"},{"text":"server.","start":3345230,"end":3345670,"confidence":0.99975586,"speaker":"C"},{"text":"Yeah.","start":3346630,"end":3347110,"confidence":0.9916992,"speaker":"A"},{"text":"Or","start":3347270,"end":3347590,"confidence":0.92041016,"speaker":"A"},{"text":"just","start":3347590,"end":3347830,"confidence":0.99902344,"speaker":"A"},{"text":"like,","start":3347830,"end":3348150,"confidence":0.99658203,"speaker":"A"},{"text":"I","start":3348870,"end":3349150,"confidence":0.9760742,"speaker":"A"},{"text":"wonder","start":3349150,"end":3349390,"confidence":0.9980469,"speaker":"A"},{"text":"if","start":3349390,"end":3349510,"confidence":0.6303711,"speaker":"A"},{"text":"there's","start":3349510,"end":3349710,"confidence":0.867513,"speaker":"A"},{"text":"like,","start":3349710,"end":3349830,"confidence":0.9819336,"speaker":"A"},{"text":"something","start":3349830,"end":3349990,"confidence":0.99902344,"speaker":"A"},{"text":"you","start":3349990,"end":3350189,"confidence":0.9926758,"speaker":"A"},{"text":"do","start":3350189,"end":3350309,"confidence":0.99853516,"speaker":"A"},{"text":"on","start":3350309,"end":3350430,"confidence":0.9970703,"speaker":"A"},{"text":"a","start":3350430,"end":3350590,"confidence":0.9946289,"speaker":"A"},{"text":"pie,","start":3350590,"end":3350950,"confidence":0.7319336,"speaker":"A"},{"text":"like","start":3351750,"end":3352150,"confidence":0.97265625,"speaker":"A"},{"text":"just","start":3352230,"end":3352470,"confidence":0.99853516,"speaker":"A"},{"text":"hook","start":3352470,"end":3352630,"confidence":0.99902344,"speaker":"A"},{"text":"it","start":3352630,"end":3352750,"confidence":0.99853516,"speaker":"A"},{"text":"up","start":3352750,"end":3352870,"confidence":1,"speaker":"A"},{"text":"to","start":3352870,"end":3352990,"confidence":1,"speaker":"A"},{"text":"a","start":3352990,"end":3353110,"confidence":0.9946289,"speaker":"A"},{"text":"CloudKit","start":3353110,"end":3353550,"confidence":0.9953613,"speaker":"A"},{"text":"database.","start":3353550,"end":3353990,"confidence":1,"speaker":"A"},{"text":"Like,","start":3353990,"end":3354190,"confidence":0.99121094,"speaker":"A"},{"text":"there's","start":3354190,"end":3354430,"confidence":0.9998372,"speaker":"A"},{"text":"a","start":3354430,"end":3354550,"confidence":1,"speaker":"A"},{"text":"lot","start":3354550,"end":3354710,"confidence":1,"speaker":"A"},{"text":"you","start":3354710,"end":3354870,"confidence":1,"speaker":"A"},{"text":"could","start":3354870,"end":3354990,"confidence":0.98828125,"speaker":"A"},{"text":"do","start":3354990,"end":3355150,"confidence":1,"speaker":"A"},{"text":"here","start":3355150,"end":3355350,"confidence":1,"speaker":"A"},{"text":"because","start":3355350,"end":3355550,"confidence":0.8598633,"speaker":"A"},{"text":"all","start":3355550,"end":3355710,"confidence":0.9995117,"speaker":"A"},{"text":"you","start":3355710,"end":3355870,"confidence":1,"speaker":"A"},{"text":"need","start":3355870,"end":3356030,"confidence":0.99902344,"speaker":"A"},{"text":"is","start":3356030,"end":3356310,"confidence":0.97314453,"speaker":"A"},{"text":"decent","start":3356710,"end":3357150,"confidence":0.9091797,"speaker":"A"},{"text":"os.","start":3357150,"end":3357510,"confidence":0.95581055,"speaker":"A"},{"text":"I","start":3358950,"end":3359230,"confidence":0.9995117,"speaker":"A"},{"text":"don't","start":3359230,"end":3359430,"confidence":0.9998372,"speaker":"A"},{"text":"know","start":3359430,"end":3359550,"confidence":0.9995117,"speaker":"A"},{"text":"anything","start":3359550,"end":3359870,"confidence":0.99975586,"speaker":"A"},{"text":"about","start":3359870,"end":3360030,"confidence":0.9995117,"speaker":"A"},{"text":"sharing.","start":3360030,"end":3360430,"confidence":0.9663086,"speaker":"A"},{"text":"I","start":3360430,"end":3360670,"confidence":1,"speaker":"A"},{"text":"haven't","start":3360670,"end":3360870,"confidence":0.9992676,"speaker":"A"},{"text":"done","start":3360870,"end":3360990,"confidence":0.9995117,"speaker":"A"},{"text":"anything","start":3360990,"end":3361310,"confidence":0.99975586,"speaker":"A"},{"text":"with","start":3361310,"end":3361470,"confidence":0.8676758,"speaker":"A"},{"text":"sharing","start":3361470,"end":3361830,"confidence":0.99731445,"speaker":"A"},{"text":"yet,","start":3361830,"end":3362110,"confidence":0.98779297,"speaker":"A"},{"text":"so","start":3362110,"end":3362310,"confidence":0.99902344,"speaker":"A"},{"text":"I","start":3362310,"end":3362430,"confidence":0.9663086,"speaker":"A"},{"text":"still","start":3362430,"end":3362590,"confidence":0.9589844,"speaker":"A"},{"text":"have","start":3362590,"end":3362750,"confidence":0.77441406,"speaker":"A"},{"text":"to","start":3362750,"end":3362870,"confidence":0.9995117,"speaker":"A"},{"text":"do","start":3362870,"end":3362990,"confidence":0.9951172,"speaker":"A"},{"text":"that","start":3362990,"end":3363190,"confidence":1,"speaker":"A"},{"text":"and","start":3363190,"end":3363390,"confidence":0.99853516,"speaker":"A"},{"text":"a","start":3363390,"end":3363510,"confidence":0.9995117,"speaker":"A"},{"text":"few","start":3363510,"end":3363630,"confidence":1,"speaker":"A"},{"text":"other","start":3363630,"end":3363830,"confidence":0.99902344,"speaker":"A"},{"text":"things,","start":3363830,"end":3364070,"confidence":0.9995117,"speaker":"A"},{"text":"but.","start":3364070,"end":3364390,"confidence":0.98876953,"speaker":"A"},{"text":"No,","start":3364940,"end":3365180,"confidence":0.6020508,"speaker":"A"},{"text":"yeah,","start":3365180,"end":3365740,"confidence":0.9869792,"speaker":"A"},{"text":"it's","start":3367740,"end":3368060,"confidence":0.97021484,"speaker":"C"},{"text":"an","start":3368060,"end":3368180,"confidence":0.99609375,"speaker":"C"},{"text":"interesting","start":3368180,"end":3368500,"confidence":0.99975586,"speaker":"C"},{"text":"idea.","start":3368500,"end":3368940,"confidence":0.98706055,"speaker":"C"},{"text":"Thank","start":3369900,"end":3370220,"confidence":0.9868164,"speaker":"A"},{"text":"you.","start":3370220,"end":3370460,"confidence":0.9975586,"speaker":"A"},{"text":"Yeah.","start":3371420,"end":3371900,"confidence":0.88997394,"speaker":"B"},{"text":"Well,","start":3371900,"end":3372100,"confidence":0.9980469,"speaker":"A"},{"text":"thank","start":3372100,"end":3372300,"confidence":1,"speaker":"A"},{"text":"you","start":3372300,"end":3372420,"confidence":0.9995117,"speaker":"A"},{"text":"for","start":3372420,"end":3372580,"confidence":0.99902344,"speaker":"A"},{"text":"joining,","start":3372580,"end":3372860,"confidence":0.96809894,"speaker":"A"},{"text":"Josh.","start":3372860,"end":3373260,"confidence":0.98461914,"speaker":"A"},{"text":"Yeah.","start":3373660,"end":3374060,"confidence":0.81844074,"speaker":"C"},{"text":"Thanks","start":3374060,"end":3374300,"confidence":1,"speaker":"C"},{"text":"for","start":3374300,"end":3374460,"confidence":0.9995117,"speaker":"C"},{"text":"hosting","start":3374460,"end":3374820,"confidence":0.9995117,"speaker":"C"},{"text":"this","start":3374820,"end":3375020,"confidence":0.9707031,"speaker":"C"},{"text":"and","start":3375020,"end":3375340,"confidence":0.99902344,"speaker":"C"},{"text":"sharing","start":3375900,"end":3376340,"confidence":0.9934082,"speaker":"C"},{"text":"this","start":3376340,"end":3376500,"confidence":0.9995117,"speaker":"C"},{"text":"info.","start":3376500,"end":3376820,"confidence":0.9995117,"speaker":"C"},{"text":"It's","start":3376820,"end":3377020,"confidence":0.9941406,"speaker":"C"},{"text":"nice.","start":3377020,"end":3377340,"confidence":1,"speaker":"C"},{"text":"Yeah.","start":3378060,"end":3378540,"confidence":0.9866536,"speaker":"A"},{"text":"If","start":3378620,"end":3378980,"confidence":0.9794922,"speaker":"A"},{"text":"you","start":3378980,"end":3379260,"confidence":0.9995117,"speaker":"A"},{"text":"ever","start":3379260,"end":3379500,"confidence":1,"speaker":"A"},{"text":"run","start":3379500,"end":3379700,"confidence":0.9995117,"speaker":"A"},{"text":"into","start":3379700,"end":3379860,"confidence":1,"speaker":"A"},{"text":"anything,","start":3379860,"end":3380180,"confidence":1,"speaker":"A"},{"text":"let","start":3380180,"end":3380300,"confidence":1,"speaker":"A"},{"text":"me","start":3380300,"end":3380459,"confidence":1,"speaker":"A"},{"text":"know.","start":3380459,"end":3380780,"confidence":0.9995117,"speaker":"A"},{"text":"Will","start":3381420,"end":3381740,"confidence":0.5800781,"speaker":"A"},{"text":"do.","start":3381740,"end":3382060,"confidence":0.99365234,"speaker":"A"},{"text":"All","start":3382940,"end":3383220,"confidence":0.9814453,"speaker":"A"},{"text":"right,","start":3383220,"end":3383500,"confidence":1,"speaker":"A"},{"text":"talk","start":3383660,"end":3383940,"confidence":1,"speaker":"A"},{"text":"to","start":3383940,"end":3384100,"confidence":1,"speaker":"A"},{"text":"you","start":3384100,"end":3384220,"confidence":0.9995117,"speaker":"A"},{"text":"later.","start":3384220,"end":3384420,"confidence":1,"speaker":"A"},{"text":"All","start":3384420,"end":3384620,"confidence":0.9223633,"speaker":"A"},{"text":"right,","start":3384620,"end":3384780,"confidence":0.9145508,"speaker":"A"},{"text":"sounds","start":3384780,"end":3385020,"confidence":1,"speaker":"A"},{"text":"good.","start":3385020,"end":3385180,"confidence":1,"speaker":"A"},{"text":"See","start":3385180,"end":3385380,"confidence":0.9975586,"speaker":"C"},{"text":"you.","start":3385380,"end":3385660,"confidence":0.54296875,"speaker":"C"},{"text":"Bye.","start":3386220,"end":3386700,"confidence":0.9375,"speaker":"A"},{"text":"Bye.","start":3386860,"end":3387340,"confidence":0.9519043,"speaker":"C"}] \ No newline at end of file diff --git a/docs/transcriptions/transcript.srt b/docs/transcriptions/transcript.srt new file mode 100644 index 00000000..77d702ca --- /dev/null +++ b/docs/transcriptions/transcript.srt @@ -0,0 +1,2708 @@ +1 +00:04:22,980 --> 00:04:25,700 +Hey, Evan, can you hear me all right? Yeah, I can hear you. + +2 +00:04:26,420 --> 00:04:28,740 +Awesome. How do I sound? Good. + +3 +00:04:30,260 --> 00:04:33,780 +I've used this microphone in ages. It's like all + +4 +00:04:34,280 --> 00:04:34,420 +dusty. + +5 +00:04:41,140 --> 00:04:44,100 +How you think I should wait like five minutes for people to come in or. + +6 +00:04:44,260 --> 00:04:47,530 +Probably. Yeah, that there's if. Yeah, + +7 +00:04:48,010 --> 00:04:51,610 +otherwise you can just. You could start, but that'll be + +8 +00:04:52,110 --> 00:04:54,250 +interesting. Do you mind if I grab a cup of coffee real quick? No, + +9 +00:04:54,750 --> 00:04:58,610 +not at all. Not at all. Okay, cool. I'm not using the AirPods + +10 +00:04:59,110 --> 00:05:01,370 +mic, so I can hear you, but you won't be able to hear me. + +11 +00:05:01,690 --> 00:05:02,250 +Okay. + +12 +00:06:02,440 --> 00:06:27,820 +It's. + +13 +00:08:51,699 --> 00:08:55,060 +Thank you for your patience. + +14 +00:09:09,010 --> 00:09:12,570 +So is it just you? It looks like it's just me. + +15 +00:09:13,070 --> 00:09:16,530 +Josh is trying to get in, but he's trying to get on on his mobile + +16 +00:09:17,030 --> 00:09:19,250 +device and I don't think that's possible with Riverside. + +17 +00:09:23,250 --> 00:09:26,130 +Surprised? I mean, I know they have an app. + +18 +00:09:27,590 --> 00:09:30,070 +Maybe he's using. I'm not sure if he's using. Using the app or not. + +19 +00:09:35,190 --> 00:09:38,630 +Should I just go? Sure. + +20 +00:09:39,830 --> 00:09:43,830 +Okay. Well, thanks for joining me, + +21 +00:09:44,330 --> 00:09:47,790 +Evan. I really appreciate it. I would + +22 +00:09:48,290 --> 00:09:49,910 +say no. I mean I do, seriously. + +23 +00:09:51,830 --> 00:09:55,070 +So yeah, this is a kind of a dry run. I would say + +24 +00:09:55,570 --> 00:09:59,670 +I'm about 60% done with this presentation about + +25 +00:10:00,310 --> 00:10:04,470 +CloudKit on the server and + +26 +00:10:04,870 --> 00:10:08,310 +we'll probably hop back and forth between Keynote and not Keynote, + +27 +00:10:08,870 --> 00:10:12,310 +but yeah. So this is + +28 +00:10:12,810 --> 00:10:16,630 +CloudKit as your backend from iOS to server side Swift. + +29 +00:10:27,600 --> 00:10:31,200 +So what is CloudKit? CloudKit is a service + +30 +00:10:32,240 --> 00:10:36,279 +launched by Apple probably a decade ago to + +31 +00:10:36,779 --> 00:10:40,520 +kind of give developers a built + +32 +00:10:41,020 --> 00:10:43,680 +in back end for storing data for their apps. + +33 +00:10:44,480 --> 00:10:48,250 +One of the biggest benefits is is how cheap it is to + +34 +00:10:48,750 --> 00:10:49,970 +use for iOS developers. + +35 +00:10:52,450 --> 00:10:55,850 +So if you have built an + +36 +00:10:56,350 --> 00:11:01,730 +app, you could just add CloudKit right here within the + +37 +00:11:02,209 --> 00:11:05,970 +Xcode project and use the + +38 +00:11:06,470 --> 00:11:10,130 +regular CloudKit API in Swift to go ahead and start using it + +39 +00:11:10,630 --> 00:11:14,430 +in your app. Here is what + +40 +00:11:14,930 --> 00:11:18,270 +it looks like to create a new record type. You can do all this through + +41 +00:11:18,430 --> 00:11:20,190 +the CloudKit dashboard. + +42 +00:11:24,190 --> 00:11:27,910 +In CloudKit you could also do this using a schema + +43 +00:11:28,410 --> 00:11:32,030 +file too. And you can export and import your schema that + +44 +00:11:32,530 --> 00:11:36,030 +way. And it's not a SQL based database, + +45 +00:11:36,530 --> 00:11:39,910 +it's much more, no sequel ish or an abstract layer + +46 +00:11:40,410 --> 00:11:44,120 +above it. But essentially you can create records + +47 +00:11:44,520 --> 00:11:48,200 +kind of like a table but not quite in your records. + +48 +00:11:49,400 --> 00:11:52,680 +You can create a struct for it. + +49 +00:11:53,180 --> 00:11:56,760 +You can just use CloudKit directly to go ahead and + +50 +00:11:57,260 --> 00:12:00,520 +then you can then plug it into your app and do fun stuff like this. + +51 +00:12:01,560 --> 00:12:05,280 +We can do things like queries and basic + +52 +00:12:05,780 --> 00:12:08,040 +database stuff. There's a lot of advantages to it. + +53 +00:12:09,280 --> 00:12:12,640 +For one, if you're doing Apple only, + +54 +00:12:13,600 --> 00:12:17,040 +then it definitely makes sense to look into, at least look + +55 +00:12:17,540 --> 00:12:18,080 +into CloudKit. + +56 +00:12:22,320 --> 00:12:25,440 +If you're just going to deploy to Apple Devices. + +57 +00:12:26,080 --> 00:12:28,720 +If you don't mind the, + +58 +00:12:29,920 --> 00:12:32,640 +the fact that it's not a regular SQL database, + +59 +00:12:34,050 --> 00:12:37,050 +that's something too to think about. If you like need a SQL database, this might + +60 +00:12:37,550 --> 00:12:41,010 +not be what you want. And then if you don't mind working with + +61 +00:12:41,510 --> 00:12:44,610 +a lot of the abstraction layers that CloudKit provides, + +62 +00:12:46,930 --> 00:12:50,730 +then this might be good for you to get started or especially + +63 +00:12:51,230 --> 00:12:54,930 +if you don't have any database experience. So as far as + +64 +00:12:55,430 --> 00:12:58,690 +like server choices, I would say CloudKit might not be your + +65 +00:12:59,190 --> 00:13:02,890 +first choice, but it certainly is a decent choice if you're + +66 +00:13:03,390 --> 00:13:04,450 +going the Apple only route. + +67 +00:13:09,970 --> 00:13:13,730 +But then the question comes in, why would you want Cloud server side CloudKit? + +68 +00:13:13,890 --> 00:13:16,610 +Why would you want to do anything with CloudKit on the server? + +69 +00:13:17,970 --> 00:13:21,690 +So here's, here's the first case. Well, this is + +70 +00:13:22,190 --> 00:13:26,090 +how you can go ahead and do that is they provide actually a REST API + +71 +00:13:26,590 --> 00:13:30,350 +for calls to CloudKit using the, if you + +72 +00:13:30,850 --> 00:13:35,710 +go to the documentation, I'll provide a link to that CloudKit Web Services which + +73 +00:13:36,510 --> 00:13:39,550 +provides a lot of the documentation for what we'll be talking about today. + +74 +00:13:40,910 --> 00:13:43,790 +A lot of this is abstracted out in the JavaScript library. + +75 +00:13:43,870 --> 00:13:47,150 +So if you want to do stuff on a website, they provide + +76 +00:13:47,230 --> 00:13:51,110 +a CloudKit JavaScript library for + +77 +00:13:51,610 --> 00:13:53,710 +that. Sorry, + +78 +00:13:56,190 --> 00:13:59,230 +just going into do not disturb mode. + +79 +00:14:07,950 --> 00:14:11,070 +They even in that web references documentation + +80 +00:14:11,570 --> 00:14:15,310 +they provide a composing web service request and all these instructions about how to go + +81 +00:14:15,810 --> 00:14:19,110 +ahead and do that. So man, was it like + +82 +00:14:19,610 --> 00:14:23,320 +half a decade ago that I built + +83 +00:14:23,820 --> 00:14:27,280 +Heart Twitch and at the time I don't think there was + +84 +00:14:27,440 --> 00:14:30,560 +anything, there was + +85 +00:14:31,060 --> 00:14:35,640 +anything like sign in with Apple even. And like I really didn't + +86 +00:14:36,140 --> 00:14:39,520 +want like to explain how harshwitch + +87 +00:14:40,020 --> 00:14:43,280 +works is you have like a watch and it will send the heart rate + +88 +00:14:43,780 --> 00:14:47,180 +to the server and then the + +89 +00:14:47,680 --> 00:14:51,100 +server will then use a web socket to push it out to a web page. + +90 +00:14:52,060 --> 00:14:55,100 +And then you would point OBS or some sort + +91 +00:14:55,600 --> 00:14:58,740 +of streaming software to the URL or to the browser window and then that way + +92 +00:14:59,240 --> 00:15:02,659 +you can stream your heart rate. That's how it works. And what I really didn't + +93 +00:15:03,159 --> 00:15:06,820 +want is a difficult way for a user to log in with + +94 +00:15:07,320 --> 00:15:10,020 +a username and password on the watch because we all know typing on the watch + +95 +00:15:10,520 --> 00:15:13,980 +is hell. So my, my thought was like, + +96 +00:15:14,320 --> 00:15:16,560 +and I didn't have sign in with Apple, right? + +97 +00:15:17,440 --> 00:15:20,880 +So my thought was why don't we use CloudKit? Because you're already signed + +98 +00:15:21,380 --> 00:15:24,080 +in a CloudKit on the Watch with your, your id. + +99 +00:15:26,640 --> 00:15:30,359 +And what you do is you log in with + +100 +00:15:30,859 --> 00:15:34,560 +a regular like email address and password in Heart Twitch on + +101 +00:15:35,060 --> 00:15:38,480 +the website. And then there's a little, there's a site, there's a part of + +102 +00:15:38,980 --> 00:15:43,060 +the site where you can sign into CloudKit and then from there + +103 +00:15:44,180 --> 00:15:47,980 +you can, because, because of the CloudKit JavaScript + +104 +00:15:48,480 --> 00:15:52,580 +library, you can then I can then pull the all + +105 +00:15:53,080 --> 00:15:55,740 +the devices because when you first launch the app on the Watch, it adds your + +106 +00:15:56,240 --> 00:15:59,740 +watch to the CloudKit database. And then I could pull that in and + +107 +00:16:00,240 --> 00:16:03,380 +then add that to my postgres database. So then there is no need for + +108 +00:16:03,880 --> 00:16:06,740 +authentication because I already have the CloudKit, + +109 +00:16:07,720 --> 00:16:11,120 +the device added in my postgres database. So it's kind of like + +110 +00:16:11,620 --> 00:16:15,520 +knows, oh yeah, this is Leo's watch, he doesn't need to authenticate. + +111 +00:16:16,020 --> 00:16:19,120 +And that way we can link devices to accounts without having to + +112 +00:16:19,620 --> 00:16:22,760 +do any sort of login process. And so this was my use case + +113 +00:16:22,919 --> 00:16:25,960 +for doing server side. + +114 +00:16:26,040 --> 00:16:29,560 +Essentially CloudKit was I could call the CloudKit web + +115 +00:16:30,060 --> 00:16:33,610 +server based + +116 +00:16:34,110 --> 00:16:37,490 +on that person's web authentication token, which we'll get + +117 +00:16:37,990 --> 00:16:40,370 +all into later. I then pull that information in. + +118 +00:16:42,050 --> 00:16:42,450 +So. + +119 +00:16:47,250 --> 00:16:47,730 +Cool. + +120 +00:16:50,770 --> 00:16:55,050 +Just checking if anybody's having issues. It doesn't look like it. So that's + +121 +00:16:55,550 --> 00:16:58,690 +good to know. So that was the private database + +122 +00:16:59,190 --> 00:17:02,990 +piece, but I actually think a much more useful case would + +123 +00:17:03,490 --> 00:17:07,150 +be the public database because the + +124 +00:17:07,650 --> 00:17:10,950 +idea would be is that you'd have some sort of app that + +125 +00:17:11,450 --> 00:17:15,790 +would use central repository of data that + +126 +00:17:16,290 --> 00:17:19,710 +it can pull information from. And I'm looking at both of these with + +127 +00:17:19,950 --> 00:17:23,310 +Bushel and then an RSS reader I'm building called Celestra + +128 +00:17:24,190 --> 00:17:27,319 +with Bushel. The. The way it's + +129 +00:17:27,819 --> 00:17:31,079 +built right now is I have this concept of hubs and + +130 +00:17:31,159 --> 00:17:34,399 +you can plug in a URL and that URL would provide or + +131 +00:17:34,899 --> 00:17:38,639 +some sort of service. That service would then provide the + +132 +00:17:39,139 --> 00:17:41,959 +Entire List of macOS restore images that are available. + +133 +00:17:44,119 --> 00:17:47,719 +But then I realized like really there's only one location for those and + +134 +00:17:48,219 --> 00:17:50,839 +each service is just going to be using the same URLs anyway. + +135 +00:17:51,970 --> 00:17:55,490 +So if I had one central repository or one central database + +136 +00:17:56,850 --> 00:18:00,250 +because they all pull from Apple, I can then parse + +137 +00:18:00,750 --> 00:18:04,530 +the web for those restore images and then store them in CloudKit and then + +138 +00:18:05,030 --> 00:18:08,770 +that way Bushel can then pull those from one + +139 +00:18:09,270 --> 00:18:12,930 +single repository. And all I would have to do, and what I'm doing now is + +140 +00:18:13,430 --> 00:18:17,130 +running basically a GitHub action or you could do like a Cron job where it + +141 +00:18:17,630 --> 00:18:21,130 +would run on Ubuntu, wouldn't even need a Mac and it would download and scrape + +142 +00:18:21,630 --> 00:18:24,430 +the web for restore images and storm in the public database. + +143 +00:18:26,350 --> 00:18:29,870 +It's the same idea with Celestra. It's an RSS reader. What if I took + +144 +00:18:30,370 --> 00:18:33,670 +those RSS RSS files + +145 +00:18:34,170 --> 00:18:37,470 +in the web and just scrape them and then store them in a CloudKit database + +146 +00:18:38,110 --> 00:18:41,310 +in a public database and then that way people can pull that + +147 +00:18:41,810 --> 00:18:42,910 +up all through CloudKit. + +148 +00:18:45,150 --> 00:18:48,550 +So the idea today is we're going to talk about how to + +149 +00:18:49,050 --> 00:18:52,380 +set something, how I set something like this up and how + +150 +00:18:52,880 --> 00:18:56,340 +you could use use my library to then go ahead and do this yourself for + +151 +00:18:56,840 --> 00:18:59,100 +any sort of work that you're going to do that where you want to use + +152 +00:18:59,600 --> 00:19:02,180 +either a public or private database in CloudKit. + +153 +00:19:03,300 --> 00:19:07,020 +So this is where I introduce myself. So I'm going to talk today about + +154 +00:19:07,520 --> 00:19:10,700 +building Miskit, which is my library I built for + +155 +00:19:11,200 --> 00:19:14,740 +doing CloudKit stuff on the server or essentially off of, + +156 +00:19:15,380 --> 00:19:17,140 +not off of Apple platforms. + +157 +00:19:19,770 --> 00:19:23,130 +Evan, do you have any questions before I keep going? No, + +158 +00:19:23,370 --> 00:19:24,890 +it's good. Good topic though. + +159 +00:19:26,810 --> 00:19:31,090 +So like I said, we have CloudKit Web Services and CloudKit + +160 +00:19:31,590 --> 00:19:35,770 +Web Services. We provide a lot of documentation. We talked about CloudKit JS + +161 +00:19:35,850 --> 00:19:39,570 +and the instructions on how to compose a web service request + +162 +00:19:40,070 --> 00:19:43,610 +which has everything I need to compose one. And back in 2020 I + +163 +00:19:44,110 --> 00:19:47,640 +did this all manually. The thing is at this + +164 +00:19:48,140 --> 00:19:51,480 +point, if you look at right there, actually if + +165 +00:19:51,980 --> 00:19:54,480 +you look at the top, you can see it hasn't been updated in over 10 + +166 +00:19:54,980 --> 00:19:58,120 +years, which is kind of crazy, + +167 +00:19:58,920 --> 00:20:02,440 +but it works. And then we + +168 +00:20:02,840 --> 00:20:06,760 +got introduced to something back in WWDC I + +169 +00:20:07,260 --> 00:20:10,840 +want to say it was 23. We got + +170 +00:20:11,340 --> 00:20:14,600 +introduced to the Open API generator which is really + +171 +00:20:15,100 --> 00:20:19,120 +nice because then we have, we can generate the Swift code + +172 +00:20:19,620 --> 00:20:23,280 +if we know what the Open API documentation looks like it. And of course + +173 +00:20:23,780 --> 00:20:27,280 +Apple doesn't provide one for CloudKit but they did provide a + +174 +00:20:27,780 --> 00:20:30,920 +pretty big piece open. If you ever you looked + +175 +00:20:31,420 --> 00:20:35,320 +at the Open API generator, it's amazing. Takes the Open API gamble file and + +176 +00:20:35,560 --> 00:20:38,880 +generates all the Swift code you need. One of the other issues + +177 +00:20:39,380 --> 00:20:43,120 +I had with first developing Miskit in 2020 + +178 +00:20:43,600 --> 00:20:47,120 +was that there was no way to like there was no abstraction + +179 +00:20:47,620 --> 00:20:51,080 +layer which could differentiate between doing something on the server or + +180 +00:20:51,580 --> 00:20:55,719 +using regular like URL session which is more targeted towards client + +181 +00:20:56,219 --> 00:20:59,880 +side. So I had + +182 +00:21:00,380 --> 00:21:02,800 +to build my own abstraction for that. Luckily Open API has, + +183 +00:21:04,080 --> 00:21:07,600 +there's open API transport I believe, which provides + +184 +00:21:08,100 --> 00:21:12,100 +an abstraction layer where you can then plug in either use Async HTTP + +185 +00:21:12,600 --> 00:21:15,660 +client, which is the server way of doing it, or you can plug in a + +186 +00:21:16,160 --> 00:21:19,380 +URL session transport, which is of course the client + +187 +00:21:19,880 --> 00:21:23,740 +way to do, provides a really great tutorial. + +188 +00:21:24,240 --> 00:21:27,740 +I highly recommend checking this out as well as the + +189 +00:21:28,240 --> 00:21:30,020 +doxy documentation that they provide. + +190 +00:21:31,860 --> 00:21:35,420 +So this is great. But then I'd have to go ahead and I'd have to + +191 +00:21:35,920 --> 00:21:39,700 +figure out a way to convert all this documentation into an open + +192 +00:21:40,200 --> 00:21:44,260 +API document. I mean, can you guess what + +193 +00:21:44,760 --> 00:21:48,260 +helped me to get build an open API document + +194 +00:21:48,760 --> 00:21:51,620 +from all this documentation? Some of the tools, + +195 +00:21:52,659 --> 00:21:54,980 +some AI tool. Yes. + +196 +00:21:56,820 --> 00:22:00,860 +AI came and I'm like, holy crap. Like AI is + +197 +00:22:01,360 --> 00:22:04,690 +really good at documenting your code, but it's also pretty darn good at taking + +198 +00:22:05,190 --> 00:22:08,450 +documentation and building code. So then I would + +199 +00:22:08,950 --> 00:22:12,290 +just plug it. I've been plugging in with Claude and it has a copy of + +200 +00:22:12,790 --> 00:22:16,490 +all the documentation in my repo and it can go ahead and edit the + +201 +00:22:16,990 --> 00:22:20,850 +open API. It's not perfect by any means, of course, but that's what unit + +202 +00:22:21,350 --> 00:22:25,770 +tests are for. And actually having integration tests + +203 +00:22:26,250 --> 00:22:31,700 +in order to do stuff so that. + +204 +00:22:35,380 --> 00:22:41,100 +Sorry, I just want to make sure nothing + +205 +00:22:46,900 --> 00:22:48,020 +I hate teams. + +206 +00:22:53,060 --> 00:22:56,420 +Okay, so great. So let's talk about. + +207 +00:22:59,700 --> 00:23:05,380 +Sorry, slides are still not done, but let's talk about authentication + +208 +00:23:05,880 --> 00:23:09,340 +methods. You can see I have the logos here, but I haven't quite cleaned + +209 +00:23:09,840 --> 00:23:14,140 +this up. So there's really two + +210 +00:23:14,640 --> 00:23:17,380 +and a half authentication methods when it comes to CloudKit. + +211 +00:23:18,420 --> 00:23:21,950 +So here is the miss demo + +212 +00:23:22,450 --> 00:23:26,070 +database. You just go in here and you can go to tokens and keys + +213 +00:23:26,570 --> 00:23:30,550 +and then that will give you access to set up either the API + +214 +00:23:31,050 --> 00:23:34,550 +if you want to do API key or API token if + +215 +00:23:35,050 --> 00:23:38,630 +you want to do a private database or a server to server keyset if + +216 +00:23:39,130 --> 00:23:41,950 +you want to do a public database. So let's talk about the API token. + +217 +00:23:42,510 --> 00:23:45,870 +Pretty simple. You just go into here, click the plus sign, + +218 +00:23:46,840 --> 00:23:49,920 +you say a name and you say whether you want to do + +219 +00:23:50,420 --> 00:23:53,920 +a post message or URL redirect. We'll get into that in a little bit in + +220 +00:23:54,420 --> 00:23:58,280 +the next section. And then whether you want to have user + +221 +00:23:58,780 --> 00:24:02,960 +info and you click save and you'll get a nice little API token + +222 +00:24:03,460 --> 00:24:06,680 +you could use in your web your web calls essentially. + +223 +00:24:09,000 --> 00:24:12,260 +API doesn't really. The API token doesn't really give you a lot of. + +224 +00:24:12,570 --> 00:24:15,330 +But what it does give you is it gives you an entry to get a + +225 +00:24:15,830 --> 00:24:19,450 +web authentication token for a user. So basically the way that + +226 +00:24:19,950 --> 00:24:22,490 +works. So you'll notice here, + +227 +00:24:23,050 --> 00:24:24,890 +when we were in this section, + +228 +00:24:27,050 --> 00:24:30,650 +we have this piece here called Sign in Callback. So you + +229 +00:24:31,150 --> 00:24:34,530 +can have either call a JavaScript, it's called a message + +230 +00:24:35,030 --> 00:24:38,730 +event, it will call a Message event and a message event will have the + +231 +00:24:39,230 --> 00:24:42,770 +metadata with the web authentication token of that user. Or you could + +232 +00:24:43,270 --> 00:24:47,250 +do URL redirect where on authentication the user has + +233 +00:24:47,750 --> 00:24:51,090 +a URL and then part of that URL is then having part of + +234 +00:24:51,590 --> 00:24:55,250 +one of the query parameters and we'll get into that. We'll then have the web + +235 +00:24:55,750 --> 00:24:59,330 +authentication token in the URL. So you + +236 +00:24:59,830 --> 00:25:03,770 +put, basically you have your website, you add the JavaScript, you need + +237 +00:25:04,330 --> 00:25:08,010 +to add the sign in with Apple. Oh, here's Josh. + +238 +00:25:14,310 --> 00:25:15,910 +Oh cool. Josh, you there? + +239 +00:25:18,790 --> 00:25:21,590 +I hope so. Good. Okay. + +240 +00:25:21,750 --> 00:25:24,429 +Hey, we were just talking about how to set up. I'm going to go back + +241 +00:25:24,929 --> 00:25:28,710 +a little bit Evan, but not too far back. Yeah, no worries. That's okay. + +242 +00:25:30,470 --> 00:25:33,790 +But we talked about setting up API token and how + +243 +00:25:34,290 --> 00:25:37,870 +to do that. So you go in + +244 +00:25:38,370 --> 00:25:41,470 +here, you just click plus, you select your sign in callback and you put in + +245 +00:25:41,970 --> 00:25:45,550 +a name and it'll give you an API token once you click + +246 +00:25:46,050 --> 00:25:46,310 +save. Basically. + +247 +00:25:50,549 --> 00:25:51,190 +Come on. + +248 +00:25:54,470 --> 00:25:58,830 +The reason you want an API token is this allows you to then have + +249 +00:25:59,330 --> 00:26:03,060 +users Sign in to CloudKit either + +250 +00:26:03,560 --> 00:26:07,540 +using, using the the web service + +251 +00:26:07,620 --> 00:26:11,380 +like Curl or you could also do it through a website using + +252 +00:26:11,880 --> 00:26:15,500 +CloudKit js. So web authentication + +253 +00:26:16,000 --> 00:26:19,260 +token we talked about how you can either do the post message or you can + +254 +00:26:19,760 --> 00:26:23,180 +do the URL redirect. Basically you have the JavaScript on + +255 +00:26:23,680 --> 00:26:26,620 +your website and there has a button, click the button, + +256 +00:26:27,120 --> 00:26:31,140 +you get this nice little window here sign in and + +257 +00:26:31,640 --> 00:26:35,020 +then when you sign in if you had selected post message, + +258 +00:26:35,340 --> 00:26:39,260 +you'll get the web authentication token and the data of the event in + +259 +00:26:39,760 --> 00:26:43,820 +JavaScript or you will get the web authentication token as a URL + +260 +00:26:44,300 --> 00:26:47,820 +in the callback URL here. Does that make sense? + +261 +00:26:50,860 --> 00:26:54,660 +Yep. Yeah. In some cases if + +262 +00:26:55,160 --> 00:26:58,520 +you scour the Internet so Stack overflow will tell you and this has happened + +263 +00:26:59,020 --> 00:27:02,360 +to me sometimes it will not be CK web authentication token, + +264 +00:27:02,860 --> 00:27:06,280 +sometimes it'll be CK session because that's what Apple likes + +265 +00:27:06,780 --> 00:27:10,120 +to do. But it's the same thing. + +266 +00:27:10,200 --> 00:27:14,160 +So you basically want to look for either property or query parameter name + +267 +00:27:14,660 --> 00:27:17,800 +and you should be good to go and then you'll have that user as well + +268 +00:27:18,300 --> 00:27:22,200 +authentication token you could do. What I, what I've + +269 +00:27:22,700 --> 00:27:27,730 +been doing is, is I've been take + +270 +00:27:28,230 --> 00:27:31,970 +like making a call to a like local server for instance and then + +271 +00:27:32,470 --> 00:27:36,330 +essentially then I could do whatever I want with that web authentication token. + +272 +00:27:36,830 --> 00:27:40,010 +As long as you have the web authentication token and the API token you can + +273 +00:27:40,510 --> 00:27:43,690 +do anything on a private database that the user has rights + +274 +00:27:44,190 --> 00:27:47,610 +to. So you can go, you can go to town with + +275 +00:27:48,110 --> 00:27:51,420 +that all this stuff gets Swift in a cookie too. + +276 +00:27:51,580 --> 00:27:54,700 +So that way it'll work. When you go back, + +277 +00:27:55,200 --> 00:27:57,500 +if you have checked the box for allow, + +278 +00:27:58,780 --> 00:28:02,180 +it's either a box or JavaScript method property that will say, hey, + +279 +00:28:02,680 --> 00:28:05,460 +I want this to persist. It'll be Swift in a, in a cookie as well. + +280 +00:28:05,960 --> 00:28:09,340 +So if you want to spelunk your cookies, you can see the web authentication + +281 +00:28:09,840 --> 00:28:13,180 +token there. So that's actually the easier of the + +282 +00:28:13,680 --> 00:28:17,300 +two. So that gives you the private database for the public database is where + +283 +00:28:17,800 --> 00:28:19,820 +you're going to need a server to server authentication. + +284 +00:28:21,340 --> 00:28:24,820 +And so to do that it's really actually not as bad + +285 +00:28:25,320 --> 00:28:28,620 +as I thought it was going to be. But you go to the new server + +286 +00:28:29,120 --> 00:28:32,500 +to server key, put in a name you want, it'll actually give you the command + +287 +00:28:33,000 --> 00:28:35,660 +you need to run and then you just paste in the public key in here. + +288 +00:28:36,380 --> 00:28:40,020 +That gives you. That will give you everything you + +289 +00:28:40,520 --> 00:28:42,780 +need. So here's how to run it. Basically, + +290 +00:28:43,990 --> 00:28:44,630 +sorry about that. + +291 +00:28:57,190 --> 00:28:59,510 +We just run that. That gives us the key. + +292 +00:29:00,710 --> 00:29:04,670 +We can go ahead and get the public key. We can also pipe + +293 +00:29:05,170 --> 00:29:08,510 +it to PB Copy and then all we have to do is paste that in + +294 +00:29:09,010 --> 00:29:10,930 +the box over here. + +295 +00:29:17,970 --> 00:29:18,690 +There we go. + +296 +00:29:25,890 --> 00:29:28,770 +It's pretty complicated to use the server key. + +297 +00:29:30,050 --> 00:29:33,450 +We can spell on the miskit code on how to do it because + +298 +00:29:33,950 --> 00:29:36,890 +it does a lot of that work for you if you have it. But you + +299 +00:29:37,390 --> 00:29:41,170 +will need the, the private key, the key id, + +300 +00:29:42,290 --> 00:29:45,490 +I think, I think that's it. And then you should be + +301 +00:29:45,990 --> 00:29:50,130 +good with having access now to the public database. + +302 +00:29:50,850 --> 00:29:54,210 +So just to go over, there's differences between the public + +303 +00:29:54,710 --> 00:29:58,050 +and private database. So this + +304 +00:29:58,550 --> 00:30:02,010 +is query. You can see my cursor, right? Query and lookup + +305 +00:30:02,510 --> 00:30:06,030 +of records is available on all but file + +306 +00:30:06,530 --> 00:30:10,150 +changes or, excuse me, record changes. It's not available on + +307 +00:30:10,650 --> 00:30:14,750 +public zones, aren't really available in public zone changes aren't available in + +308 +00:30:15,250 --> 00:30:18,870 +public notifications. Zone notifications aren't available in public, + +309 +00:30:19,670 --> 00:30:23,350 +but query notifications are. And you can also do + +310 +00:30:23,850 --> 00:30:27,310 +any stuff with assets which are basically binary files. You can + +311 +00:30:27,810 --> 00:30:32,190 +also do that in all of them. You can't do query + +312 +00:30:32,690 --> 00:30:36,110 +notifications on shared. Shared would essentially work like + +313 +00:30:36,610 --> 00:30:39,810 +private essentially. So it's just a matter + +314 +00:30:40,310 --> 00:30:42,610 +of who. Who's the owner and how is it shared. + +315 +00:30:44,690 --> 00:30:48,370 +So one of the big challenges I think we've all faced this when we've + +316 +00:30:48,870 --> 00:30:53,370 +dealt with certain web services is field type polymorphism. + +317 +00:30:53,870 --> 00:30:56,730 +If you've done JSON where you don't know what type you're getting back or what + +318 +00:30:57,230 --> 00:30:59,410 +data you're getting back, this can Be a bit challenging. + +319 +00:31:00,530 --> 00:31:03,650 +So if you look at the documentation + +320 +00:31:04,290 --> 00:31:08,290 +in Web Services Reference, there is a, + +321 +00:31:09,090 --> 00:31:12,610 +there's a page called types and dictionaries and there is + +322 +00:31:13,110 --> 00:31:16,890 +types. There's different type values for each field. If you're familiar + +323 +00:31:17,390 --> 00:31:20,650 +with CloudKit, you've seen this, right? So you have an asset + +324 +00:31:21,150 --> 00:31:25,330 +which is basically a, a binary + +325 +00:31:25,830 --> 00:31:29,650 +file. You have bytes which is + +326 +00:31:30,150 --> 00:31:33,620 +essentially a 60 byte base 64 encoded string, + +327 +00:31:34,740 --> 00:31:38,460 +date type which is returned as a number. Double is + +328 +00:31:38,960 --> 00:31:41,620 +returned as a number because These are the JavaScript types. + +329 +00:31:42,260 --> 00:31:46,140 +Int is returned as a number and then + +330 +00:31:46,640 --> 00:31:49,940 +there's location reference and then + +331 +00:31:50,020 --> 00:31:53,420 +string and list. And how would you like, + +332 +00:31:53,920 --> 00:31:57,100 +how do you do adjacent object like this? How would you + +333 +00:31:57,600 --> 00:31:59,860 +even represent this in Swift? Because you don't know what type you're going to get. + +334 +00:32:01,350 --> 00:32:04,510 +So like I said, this is a work in progress. + +335 +00:32:05,010 --> 00:32:08,710 +Sorry. So what I do, I don't know how much you can see this. + +336 +00:32:09,110 --> 00:32:13,910 +I'm going to actually move over to my documentation + +337 +00:32:14,410 --> 00:32:18,590 +here at this point. So how + +338 +00:32:19,090 --> 00:32:20,070 +are we doing on time? We good? + +339 +00:32:22,550 --> 00:32:25,590 +Yeah, I think, I think we're doing good. Okay, cool. + +340 +00:32:26,090 --> 00:32:30,240 +Any, do you want to ask questions? I don't + +341 +00:32:30,740 --> 00:32:32,160 +have anything right now. + +342 +00:32:33,760 --> 00:32:37,880 +Same nothing right now. But this seems applicable to things I'll + +343 +00:32:38,380 --> 00:32:40,480 +be doing coming up. Okay, cool. + +344 +00:32:43,200 --> 00:32:46,640 +So we have set up in the + +345 +00:32:46,800 --> 00:32:50,400 +open. So we have an open API YAML file that you can + +346 +00:32:50,900 --> 00:32:55,370 +pull up in Miskit, which is basically every like the + +347 +00:32:55,870 --> 00:32:59,290 +documentation converted to YAML. And so what we + +348 +00:32:59,790 --> 00:33:03,410 +do is you can set up in the YAML the + +349 +00:33:03,910 --> 00:33:08,330 +field value requests and they have an enum type essentially for, + +350 +00:33:12,090 --> 00:33:15,490 +for open API. So and then, + +351 +00:33:15,990 --> 00:33:18,810 +so this has, you know, it could be one of either any of these types + +352 +00:33:18,860 --> 00:33:22,090 +of. And then there's an enum in + +353 +00:33:22,590 --> 00:33:26,210 +case you have a list. So if you have a + +354 +00:33:26,710 --> 00:33:30,690 +list value type there is an extra property called + +355 +00:33:31,010 --> 00:33:33,810 +type and then that will tell you what type the. + +356 +00:33:34,450 --> 00:33:38,450 +The list is. And it's homo homomorphic. + +357 +00:33:38,690 --> 00:33:42,210 +It's all the same list type. You can't have lists of different types. + +358 +00:33:44,050 --> 00:33:49,230 +And then we have here again + +359 +00:33:49,730 --> 00:33:52,750 +field value. Sometimes the type is available, + +360 +00:33:52,910 --> 00:33:56,590 +sometimes it's not. But basically we have all the different + +361 +00:33:56,750 --> 00:33:59,950 +value types available to us in a CK value. + +362 +00:34:01,950 --> 00:34:05,670 +And then this is. Then the Open API + +363 +00:34:06,170 --> 00:34:09,150 +generator essentially builds this for me which is. + +364 +00:34:09,710 --> 00:34:13,630 +Has an enum and a struck for field field value request + +365 +00:34:15,329 --> 00:34:18,569 +and then it does all the decoding for me. Thankfully I didn't have to do + +366 +00:34:19,069 --> 00:34:19,169 +any of it. + +367 +00:34:23,089 --> 00:34:26,569 +And then yeah, I just wanted to + +368 +00:34:27,069 --> 00:34:31,969 +cover that piece where we show how we deal with these kind of like polymorphic + +369 +00:34:32,469 --> 00:34:35,969 +types and how those work. The next thing I + +370 +00:34:36,469 --> 00:34:39,929 +want to cover is error handling. So if you + +371 +00:34:40,429 --> 00:34:43,750 +look at the documentation gives you. If you get + +372 +00:34:44,250 --> 00:34:48,350 +an error we get something like this and + +373 +00:34:48,850 --> 00:34:52,030 +then that will show you in the. In the table actually shows you what + +374 +00:34:52,530 --> 00:34:56,150 +each error means. So again we do + +375 +00:34:56,650 --> 00:35:00,430 +like an enum in YAML. It's basically a string and then + +376 +00:35:00,930 --> 00:35:05,030 +we have everything else be a string. And then the open API generator will + +377 +00:35:05,530 --> 00:35:09,860 +automatically generate this which gives us the server + +378 +00:35:10,360 --> 00:35:13,980 +error code and the error response. It'll also do all this stuff + +379 +00:35:14,480 --> 00:35:18,540 +here, which is really nice. And then + +380 +00:35:18,620 --> 00:35:22,620 +we've then in our. We've abstracted a lot of this in miskit. + +381 +00:35:22,940 --> 00:35:27,100 +So that way we also have now a cloud cloud + +382 +00:35:27,600 --> 00:35:31,820 +error type which gives us a lot more info regarding that. + +383 +00:35:33,900 --> 00:35:37,360 +So that's how we handle errors. And everything I + +384 +00:35:37,860 --> 00:35:42,200 +do in the abs, the more abstract higher up stuff is done using + +385 +00:35:42,360 --> 00:35:46,360 +type throws like I have type throws and everything. So that's + +386 +00:35:46,860 --> 00:35:50,920 +how I handle that. Let me check one + +387 +00:35:51,420 --> 00:35:52,200 +last piece I wanted to cover. + +388 +00:35:54,920 --> 00:35:58,520 +The last piece I want to cover is really cool. And that is the + +389 +00:35:59,020 --> 00:36:03,160 +authentication layer. So Open API provides what's called middleware + +390 +00:36:04,440 --> 00:36:08,080 +and that allows you to, when you create a client or a server, you can + +391 +00:36:08,580 --> 00:36:11,840 +plug that in and it will handle like let's say you need to make modifications + +392 +00:36:12,340 --> 00:36:15,760 +with the request or response. When it comes in, you can intercept it + +393 +00:36:16,260 --> 00:36:17,800 +and make whatever modifications you want to make. + +394 +00:36:19,239 --> 00:36:22,880 +And in this case what we've done is I've + +395 +00:36:23,380 --> 00:36:27,840 +created an authentication middleware which + +396 +00:36:28,340 --> 00:36:31,790 +then sees if you have what's called + +397 +00:36:32,290 --> 00:36:35,630 +a token manager and an authentic you have + +398 +00:36:36,130 --> 00:36:39,910 +that and an authentication method. And the way it works is + +399 +00:36:40,410 --> 00:36:43,790 +you pick what type of authentication you want to use. If you already have like + +400 +00:36:44,290 --> 00:36:47,710 +a pre existing web token or you already have, or you, you know, + +401 +00:36:48,210 --> 00:36:51,190 +have your key ID and your private key already, or you just have the API + +402 +00:36:51,690 --> 00:36:54,870 +token. We've created basically a middleware that uses + +403 +00:36:55,370 --> 00:36:59,120 +that. So this + +404 +00:36:59,620 --> 00:37:03,320 +is how it creates the headers for server to server. So it does + +405 +00:37:03,820 --> 00:37:07,760 +all this for us. And then what + +406 +00:37:08,260 --> 00:37:11,760 +I added, which I think is really nice, is called the adaptive token manager. + +407 +00:37:12,240 --> 00:37:17,360 +And the idea with that is like let's say you're + +408 +00:37:17,860 --> 00:37:21,200 +using a client and you have the web authentication token now + +409 +00:37:21,440 --> 00:37:25,090 +and then this allows you to upgrade with that web authentication + +410 +00:37:25,590 --> 00:37:27,730 +token to the private database and have access to that. + +411 +00:37:30,530 --> 00:37:33,970 +So and then all the, all the signing is done + +412 +00:37:34,470 --> 00:37:37,650 +before you in miskit for the server to server because stuff that + +413 +00:37:38,150 --> 00:37:41,170 +needs to be signed, etc. And it takes care of all that. + +414 +00:37:41,570 --> 00:37:45,610 +All stuff that Claude was essentially able to decipher + +415 +00:37:46,110 --> 00:37:50,060 +from the documentation. + +416 +00:37:52,620 --> 00:37:54,300 +There's one more thing I wanted to show. + +417 +00:37:56,380 --> 00:38:00,220 +If you want to hop in with a question while I pull something up, + +418 +00:38:00,300 --> 00:38:00,940 +feel free. + +419 +00:38:21,190 --> 00:38:24,390 +No questions. Cool. + +420 +00:38:24,790 --> 00:38:28,630 +So I'm going to show one last thing and that is how + +421 +00:38:28,710 --> 00:38:30,310 +do we actually deploy this? + +422 +00:38:33,350 --> 00:38:36,950 +Is this too big, too small? Looks okay. + +423 +00:38:37,590 --> 00:38:40,070 +That looks good. Yeah, it looks good. Okay, cool. + +424 +00:38:43,850 --> 00:38:47,890 +So essentially what I've done is I'm using GitHub + +425 +00:38:48,390 --> 00:38:50,410 +Actions. There's a way you can. + +426 +00:38:53,130 --> 00:38:56,689 +This is all public by the way, so I will provide + +427 +00:38:57,189 --> 00:39:00,570 +URLs in the Slack or something. Let's do this one. + +428 +00:39:02,410 --> 00:39:07,220 +So this is a Swift package for + +429 +00:39:07,720 --> 00:39:10,660 +Bushel. It's called Bushel Cloud. It pulls the stuff up from. + +430 +00:39:11,220 --> 00:39:14,740 +Uses Miskit to go ahead and + +431 +00:39:16,740 --> 00:39:20,340 +pull, get access to CloudKit and + +432 +00:39:21,060 --> 00:39:24,860 +let me go back to the workflow. How familiar + +433 +00:39:25,360 --> 00:39:26,580 +are you with GitHub workflows? + +434 +00:39:29,860 --> 00:39:32,980 +Sadly not had the chance to work too deeply with them yet. + +435 +00:39:33,690 --> 00:39:37,490 +Okay. Basically it's like for CI, but you can also set + +436 +00:39:37,990 --> 00:39:41,850 +it up on a schedule. So I did that and then + +437 +00:39:42,890 --> 00:39:46,490 +it runs the scheduled job and then I just execute. + +438 +00:39:50,650 --> 00:39:54,650 +So then this was refactored over here into + +439 +00:39:55,150 --> 00:39:58,490 +an action. There we go. + +440 +00:39:59,540 --> 00:40:03,460 +And I have all sorts of stuff here for + +441 +00:40:05,380 --> 00:40:10,300 +like this is generic essentially, but all + +442 +00:40:10,800 --> 00:40:14,220 +these, the environment, etc. These are all passed from + +443 +00:40:14,720 --> 00:40:17,980 +that workflow into here. These are basically either API keys + +444 +00:40:18,480 --> 00:40:22,100 +or the information that I need for accessing Cloud, the public, + +445 +00:40:24,020 --> 00:40:28,120 +public database. Right. And then I + +446 +00:40:28,620 --> 00:40:31,880 +already pre built the binary. So we + +447 +00:40:32,380 --> 00:40:35,960 +already have that. We're running this on Ubuntu because + +448 +00:40:36,460 --> 00:40:40,280 +it's the default. Look at it. If there + +449 +00:40:40,780 --> 00:40:43,840 +is no binary, it goes ahead and builds the binary for me. + +450 +00:40:44,000 --> 00:40:45,200 +So that's what this is doing. + +451 +00:40:47,120 --> 00:40:50,640 +And then we make sure the binary works. + +452 +00:40:50,880 --> 00:40:54,450 +We make, we make it executable, we validate, make sure all the + +453 +00:40:55,010 --> 00:40:58,690 +API secrets are there. We then go ahead + +454 +00:40:58,930 --> 00:41:02,370 +and this validates the pim. But essentially this is the fun part. + +455 +00:41:03,410 --> 00:41:06,770 +We go ahead, we have all our inputs for the private key, + +456 +00:41:07,270 --> 00:41:09,570 +the key id, environment, container id. + +457 +00:41:10,610 --> 00:41:13,410 +And then I use Virtual Buddy for signing verification. + +458 +00:41:14,050 --> 00:41:14,450 +And. + +459 +00:41:18,460 --> 00:41:21,940 +It then goes in and it runs the + +460 +00:41:22,440 --> 00:41:25,660 +sync and then we'll go in. + +461 +00:41:25,980 --> 00:41:29,500 +Basically it pulls from several websites information + +462 +00:41:29,580 --> 00:41:32,939 +about macrosos, restore images and checks whether they're signed. + +463 +00:41:33,340 --> 00:41:37,540 +And then it goes ahead and it adds those to + +464 +00:41:38,040 --> 00:41:41,780 +the database. And then what this does is it exports the information in + +465 +00:41:42,280 --> 00:41:44,580 +a run. Let's, let's take a look, see if I have one. I can show + +466 +00:41:45,080 --> 00:41:47,420 +you. Oh, there's one scheduled. + +467 +00:41:50,060 --> 00:41:53,700 +Yeah, here we go. So there's 57 + +468 +00:41:54,200 --> 00:41:55,580 +new restore images created, + +469 +00:41:56,300 --> 00:41:58,300 +177 updated. + +470 +00:41:58,780 --> 00:42:02,300 +234 total. No operations + +471 +00:42:02,380 --> 00:42:05,900 +failed. I also store Xcode versions and Swift versions. + +472 +00:42:06,780 --> 00:42:10,460 +Those get stored as well. Had to rebuild it, + +473 +00:42:10,630 --> 00:42:11,830 +but here is the results. + +474 +00:42:13,750 --> 00:42:17,750 +I'm not going to pull that up, but it's essentially updated + +475 +00:42:18,250 --> 00:42:22,470 +my CloudKit database and + +476 +00:42:22,550 --> 00:42:25,870 +that's all in the public database. And then maybe even by + +477 +00:42:26,370 --> 00:42:29,910 +the time I present this, I'll have a working example in Bushel with that example + +478 +00:42:30,410 --> 00:42:33,750 +working, which would be awesome. Celestra, + +479 +00:42:33,990 --> 00:42:37,190 +same idea. So this looks like it was a RSS update. + +480 +00:42:38,910 --> 00:42:42,830 +We get the workflow file and. + +481 +00:42:43,330 --> 00:42:46,110 +Oh, sorry, I should point out, because you're probably wondering where is all these. + +482 +00:42:46,610 --> 00:42:50,150 +The stuff all these secrets stored? Yes, they are stored in + +483 +00:42:50,650 --> 00:42:53,910 +Actions secrets right here. So we have + +484 +00:42:54,410 --> 00:42:58,190 +our private key ID API key from + +485 +00:42:58,690 --> 00:43:01,230 +Virtual Buddy. So that's all stored there. + +486 +00:43:01,870 --> 00:43:05,830 +Here is Celestra. It's for updating RSS + +487 +00:43:06,330 --> 00:43:09,930 +feeds. So it just basically goes through. You can look at the Swift code + +488 +00:43:10,430 --> 00:43:14,490 +it goes through, pulls RSS feeds and updates them into a CloudKit + +489 +00:43:15,530 --> 00:43:18,490 +record or what do you call it? Yeah, record type. + +490 +00:43:19,850 --> 00:43:22,210 +And I of course try to do it in such a way not to hammer + +491 +00:43:22,710 --> 00:43:24,170 +people, but same idea, + +492 +00:43:27,050 --> 00:43:30,610 +yeah, it goes ahead and it runs the + +493 +00:43:31,110 --> 00:43:35,890 +binary it updates and then I also have like actual parameters + +494 +00:43:36,390 --> 00:43:40,170 +that I take to to filter out, like which RSS feeds are high priority + +495 +00:43:40,670 --> 00:43:44,330 +and which ones aren't based on the audience and etc. So yeah, + +496 +00:43:44,890 --> 00:43:48,410 +so that's deployment. That's how you can get that working. + +497 +00:43:48,810 --> 00:43:53,130 +There's weird stuff with cloud with GitHub that + +498 +00:43:53,690 --> 00:43:57,210 +I've noticed. If you haven't updated it in a while, it doesn't run these + +499 +00:43:57,710 --> 00:43:59,570 +cron jobs. So I need to figure out a how to get around it or + +500 +00:44:00,070 --> 00:44:04,030 +find another service to do it. This is all free because + +501 +00:44:04,110 --> 00:44:07,870 +it's public and it is running + +502 +00:44:08,370 --> 00:44:09,870 +on Ubuntu. So that's really great. + +503 +00:44:12,350 --> 00:44:16,070 +And the storage on CloudKit is dirt cheap, which is even + +504 +00:44:16,570 --> 00:44:16,830 +more awesome. + +505 +00:44:20,030 --> 00:44:23,990 +Sorry, let's see what else. I just + +506 +00:44:24,490 --> 00:44:27,150 +want to make sure I covered all my slides. The last thing I'm going to + +507 +00:44:27,650 --> 00:44:28,670 +talk about is just what are my plans? + +508 +00:44:30,390 --> 00:44:33,390 +Excuse me. So I don't know if you check. Follow me. + +509 +00:44:33,890 --> 00:44:34,550 +But I just released. + +510 +00:44:41,910 --> 00:44:45,750 +I just released Alpha 5 that has lookup zones, + +511 +00:44:46,250 --> 00:44:50,150 +fetch, record changes and upload assets. Upload the assets is pretty awesome. + +512 +00:44:50,230 --> 00:44:53,150 +When I saw that work because I was like, cool, I can actually upload a + +513 +00:44:53,650 --> 00:44:57,630 +binary to CloudKit, which is awesome. We got + +514 +00:44:58,130 --> 00:45:01,790 +query filters to work for in and not in, so you could do that I + +515 +00:45:02,290 --> 00:45:05,510 +have plans to continue working on this because I think there's a big future for + +516 +00:45:06,010 --> 00:45:09,590 +something like this for a lot of people. Yes, + +517 +00:45:10,090 --> 00:45:13,950 +you can technically use this in Android or Windows because the Swift + +518 +00:45:14,270 --> 00:45:17,670 +thing does compile in Android and Windows. You can see I already added support for + +519 +00:45:18,170 --> 00:45:22,360 +that. This is the support I recently had. And then we're. + +520 +00:45:22,860 --> 00:45:25,880 +I'm just kind of like going through each of these because as great as AI + +521 +00:45:26,380 --> 00:45:30,120 +is, it's not perfect. So we're just kind of going through these piece + +522 +00:45:30,620 --> 00:45:35,720 +by piece with each version and hammering these away and + +523 +00:45:36,220 --> 00:45:40,160 +then this is actually done. I don't even know why that's there. But yeah, + +524 +00:45:40,660 --> 00:45:43,960 +I think system field integration might already be there and there's + +525 +00:45:44,460 --> 00:45:48,120 +a few other things. Eventually I'd like to add support. + +526 +00:45:48,200 --> 00:45:52,880 +So there, there's a whole API for CloudKit schema management that + +527 +00:45:53,380 --> 00:45:55,720 +I could. That would be awesome if I could figure out how to do that. + +528 +00:45:56,220 --> 00:45:58,640 +If I could figure out how to do key path query filtering, that would be + +529 +00:45:59,140 --> 00:46:02,760 +fantastic. And yeah, + +530 +00:46:03,260 --> 00:46:06,080 +but there's a. I mean the basics is there as far as if you want + +531 +00:46:06,580 --> 00:46:09,080 +to do anything with a record, it's pretty much there. + +532 +00:46:09,720 --> 00:46:13,160 +One thing with Celestra is I'd love to be able to do like test out + +533 +00:46:13,660 --> 00:46:17,840 +subscriptions and see how that works. So yeah, + +534 +00:46:18,340 --> 00:46:20,040 +that's really the bulk of my presentation today. + +535 +00:46:21,800 --> 00:46:24,880 +Now is. Now it's time to ask me a ton of questions and make me + +536 +00:46:25,380 --> 00:46:26,600 +feel dumb. Go for it. + +537 +00:46:28,440 --> 00:46:32,160 +No, there's a lot there to. To absorb. + +538 +00:46:32,660 --> 00:46:36,000 +But I, I like the concept and I know you've been working on this + +539 +00:46:36,500 --> 00:46:39,720 +for a while and I always thought it was a pretty cool, pretty cool + +540 +00:46:40,030 --> 00:46:42,190 +idea and implementation of this. + +541 +00:46:42,750 --> 00:46:43,470 +Questions? + +542 +00:46:48,990 --> 00:46:50,030 +So with something like. + +543 +00:46:54,110 --> 00:46:58,110 +Accessing CloudKit through the web, is this setup more + +544 +00:46:58,610 --> 00:47:02,270 +ideal for having your server do + +545 +00:47:02,670 --> 00:47:06,650 +the authentication to CloudKit with Miskit or is + +546 +00:47:07,150 --> 00:47:10,530 +miskit something that you could put into even like a client side, + +547 +00:47:12,850 --> 00:47:17,010 +you know, like non Swift application or + +548 +00:47:17,510 --> 00:47:20,970 +I guess not non Swift but like non like app application. I'm thinking in + +549 +00:47:21,470 --> 00:47:22,049 +the context of like a. + +550 +00:47:25,730 --> 00:47:30,290 +I guess if I wanted to create a something + +551 +00:47:30,790 --> 00:47:33,410 +accessing CloudKit that is not your typical Mac or iOS app. + +552 +00:47:34,880 --> 00:47:36,160 +Can you be more specific? + +553 +00:47:37,840 --> 00:47:42,040 +I'm looking into one. One approach would be browser + +554 +00:47:42,540 --> 00:47:46,000 +extensions. So for + +555 +00:47:46,500 --> 00:47:48,240 +like a non Safari browser. Yes. + +556 +00:47:50,400 --> 00:47:54,120 +Yeah, this would be great. So basically the way you'd want + +557 +00:47:54,620 --> 00:47:58,240 +that to work, like the sticky part to me would be getting the web authentication + +558 +00:47:58,740 --> 00:48:01,090 +token. Other than that, like have at it. + +559 +00:48:04,610 --> 00:48:07,810 +So I'm gonna, I'm gonna be devil's advocate. Why not just use + +560 +00:48:08,310 --> 00:48:11,490 +the CloudKit JavaScript library. If it's an extension, + +561 +00:48:12,450 --> 00:48:14,290 +my brain jumps to Swift first. + +562 +00:48:15,730 --> 00:48:18,930 +Right. But it's the reason I'm asking that is like it's a, + +563 +00:48:19,410 --> 00:48:23,490 +it's already a web extension. I would assume that is true. That it's + +564 +00:48:23,990 --> 00:48:26,290 +90 web based or JavaScript based. + +565 +00:48:27,120 --> 00:48:30,560 +So that's where I'm just like, well, you may as well. Like, I would love. + +566 +00:48:30,640 --> 00:48:33,600 +I don't want to. Like, I love tooting my own horn. Right. But like, + +567 +00:48:34,880 --> 00:48:37,120 +like why not just. Unless you're. + +568 +00:48:40,720 --> 00:48:43,840 +Unless you're like building a executable, + +569 +00:48:44,160 --> 00:48:45,920 +I guess, or an app. Ish. + +570 +00:48:47,760 --> 00:48:52,040 +And I guess another application for this would be doing + +571 +00:48:52,540 --> 00:48:56,280 +CloudKit stuff server side and then providing my own API layer + +572 +00:48:56,780 --> 00:48:59,860 +over it. Yep, yep. So that's. + +573 +00:49:00,360 --> 00:49:03,740 +Yeah. Are we talking private database or public database? Private. + +574 +00:49:05,580 --> 00:49:09,140 +So in that case, basically like you'd have to go + +575 +00:49:09,640 --> 00:49:13,380 +the Hard Twitch route and you would have to + +576 +00:49:13,880 --> 00:49:17,420 +provide a way to get their web authentication + +577 +00:49:17,920 --> 00:49:21,260 +token, essentially, if that makes sense. And then store + +578 +00:49:21,760 --> 00:49:24,140 +it in Postgres or whatever the hell you want to do. Like that's, that's the + +579 +00:49:24,640 --> 00:49:27,520 +way I did it with Hard Twitch. But once you have that, you can do + +580 +00:49:28,020 --> 00:49:31,200 +anything you want on the server with their private database, + +581 +00:49:31,700 --> 00:49:34,480 +if that makes sense. It does. Yep. + +582 +00:49:34,560 --> 00:49:38,240 +Yep. A couple of things I wanted to bring up, + +583 +00:49:38,320 --> 00:49:39,520 +so let's take a look. + +584 +00:49:44,000 --> 00:49:47,400 +So part of my + +585 +00:49:47,900 --> 00:49:51,880 +other presentation is working, talking about cross + +586 +00:49:52,380 --> 00:49:56,760 +platform automation type stuff. And the + +587 +00:49:57,260 --> 00:50:00,680 +one issue I've run into is. So it basically builds on everything. + +588 +00:50:00,920 --> 00:50:01,560 +Right now. + +589 +00:50:07,560 --> 00:50:10,520 +I'm going to share something. Hey guys, + +590 +00:50:11,000 --> 00:50:14,680 +I got to drop. But it was good presentation, Leo. Thank you. + +591 +00:50:14,840 --> 00:50:17,640 +Yeah, yeah. If I have more questions, if you have any feedback, just hit me + +592 +00:50:18,140 --> 00:50:21,590 +up on Slack. Sounds good. Cool, thank you. Thank you so much + +593 +00:50:22,090 --> 00:50:25,910 +for helping me set this up. Yeah, talk to you later. Thank you. Bye bye. + +594 +00:50:28,870 --> 00:50:31,430 +Yeah, so if you had something else to show, I'm happy to look for. + +595 +00:50:31,930 --> 00:50:34,390 +I'm here for a few more minutes as well. Yeah, yeah, yeah. + +596 +00:50:38,790 --> 00:50:42,070 +So I have the workflow working here and it + +597 +00:50:42,570 --> 00:50:46,120 +does Ubuntu, it does Windows, it does Android. + +598 +00:50:46,620 --> 00:50:50,920 +So all that stuff is available to you. I would never recommend using Miskit + +599 +00:50:51,420 --> 00:50:54,240 +on an Apple platform for obvious reasons, like what's the point? + +600 +00:50:55,600 --> 00:50:59,360 +True. Unless there's something special that I provide that CloudKit doesn't like, + +601 +00:50:59,440 --> 00:51:03,520 +I don't get it. Right. But we have an + +602 +00:51:04,020 --> 00:51:07,640 +issue. So I just started dabbling. I haven't really done anything + +603 +00:51:08,140 --> 00:51:11,730 +with wasm, but I did definitely try. Like I added support for + +604 +00:51:12,230 --> 00:51:14,890 +WASM in my, in my Swift build action. + +605 +00:51:17,210 --> 00:51:21,050 +The thing about WASA is it does not provide. It doesn't have a transport + +606 +00:51:21,130 --> 00:51:24,410 +available. So we talked about transports, + +607 +00:51:26,010 --> 00:51:30,090 +I think. Did you hear about that part about the Open API generator and transports? + +608 +00:51:31,370 --> 00:51:33,690 +I think I was coming in at that point. + +609 +00:51:34,410 --> 00:51:36,670 +Okay. When you create a client, + +610 +00:51:37,630 --> 00:51:42,630 +so underneath the client you + +611 +00:51:43,130 --> 00:51:46,990 +have what's called a client transport. This is so underneath this + +612 +00:51:47,490 --> 00:51:51,270 +client, this is an abstraction layer above. So this is not the right + +613 +00:51:51,770 --> 00:51:53,390 +one. Where's the public one? + +614 +00:52:00,680 --> 00:52:05,440 +But anyway, there is here + +615 +00:52:05,940 --> 00:52:06,920 +CloudKit service maybe. + +616 +00:52:09,560 --> 00:52:13,640 +Yeah, here we go. So the CloudKit service has + +617 +00:52:14,140 --> 00:52:17,960 +a client and part of the client is being able + +618 +00:52:19,960 --> 00:52:23,560 +to say what transport you use in Open API. + +619 +00:52:24,760 --> 00:52:29,330 +And there's + +620 +00:52:29,830 --> 00:52:33,730 +two transports available right now. One is, + +621 +00:52:36,850 --> 00:52:40,210 +one is your regular URL session for clients, which. + +622 +00:52:40,710 --> 00:52:43,810 +That makes sense. Right. And then there's the Async HTTP + +623 +00:52:44,310 --> 00:52:47,970 +client which is typically used like Swift NEO based for servers. + +624 +00:52:49,330 --> 00:52:53,170 +The thing is that neither of those are available in wasp. + +625 +00:52:54,290 --> 00:52:57,810 +Do you know what WASM is? I have no experience with it, but yes. + +626 +00:52:58,850 --> 00:53:02,290 +Okay. It's. It's the web browser. Right. So. + +627 +00:53:02,690 --> 00:53:04,850 +So you really can't use Miskit in. + +628 +00:53:06,450 --> 00:53:10,210 +In the. In WASM yet because there is no transport. Now having said that, + +629 +00:53:10,530 --> 00:53:12,450 +why on earth would you use. + +630 +00:53:13,090 --> 00:53:16,970 +Awesome. Why would you use Miskit in the browser? Why not just use CloudKit + +631 +00:53:17,470 --> 00:53:20,700 +js? So that's essentially, + +632 +00:53:21,580 --> 00:53:22,060 +you know, + +633 +00:53:29,260 --> 00:53:30,940 +What other questions do you have? + +634 +00:53:35,660 --> 00:53:41,340 +My brain is mushy right now, so because + +635 +00:53:41,840 --> 00:53:45,450 +of my presentation or because other things, I got two hours of + +636 +00:53:45,950 --> 00:53:50,170 +sleep. Oh, I'm so sorry. So I'm + +637 +00:53:50,670 --> 00:53:51,450 +following as best as I can. + +638 +00:53:54,330 --> 00:53:57,410 +Snuggling. Yeah, + +639 +00:53:57,910 --> 00:54:01,410 +the intro was basically how I originally built it + +640 +00:54:01,910 --> 00:54:06,210 +for hard Twitch in 2020 for a private database login for + +641 +00:54:06,710 --> 00:54:09,210 +the Apple Watch because I don't want to have a login screen. And so basically + +642 +00:54:09,710 --> 00:54:12,490 +there's a way in the web browser to link your Apple Watch to your account + +643 +00:54:12,990 --> 00:54:16,280 +and then from there you don't need to authenticate anymore. Nice. I built + +644 +00:54:16,780 --> 00:54:20,280 +that all from hand and then in 23 they + +645 +00:54:20,780 --> 00:54:24,720 +came out with the Open API generator which was like, oh wait, what if + +646 +00:54:24,800 --> 00:54:28,160 +I can create an open API file out of + +647 +00:54:28,320 --> 00:54:30,800 +Apple's 10 year old documentation? + +648 +00:54:33,120 --> 00:54:36,280 +That'd be a lot of work, but I could do it. And I + +649 +00:54:36,780 --> 00:54:40,480 +don't know if you heard, but there was this thing that came out a + +650 +00:54:40,980 --> 00:54:45,340 +couple years ago called AI and it's + +651 +00:54:45,840 --> 00:54:49,140 +really good at creating documentation for your code, but it's also really good at creating + +652 +00:54:49,640 --> 00:54:53,940 +code for your documentation. And so I was like, oh yeah, + +653 +00:54:54,440 --> 00:54:57,900 +this is great. Like I can just, I can just Feed it + +654 +00:54:58,400 --> 00:55:01,940 +the documentation and go from there. + +655 +00:55:02,020 --> 00:55:05,140 +And, like, basically, I've been going step by step through. + +656 +00:55:05,940 --> 00:55:09,300 +Like I said, if you looked at the miskit repo, + +657 +00:55:09,800 --> 00:55:14,620 +like, I'm going through step by step and adding new APIs based + +658 +00:55:15,120 --> 00:55:18,180 +on what's available in the documentation, piece by piece. And I would say at this + +659 +00:55:18,680 --> 00:55:22,420 +point, it's like most of the really, like 80% of that people use + +660 +00:55:22,920 --> 00:55:26,700 +is there. There's like, stuff like subscriptions and zones that I'm still trying + +661 +00:55:27,200 --> 00:55:30,940 +to figure out, but it's. It's pretty close to done at this point. + +662 +00:55:31,260 --> 00:55:31,900 +Mm. + +663 +00:55:35,110 --> 00:55:38,590 +If you use it. Yeah, it's one of those. Because I. Go ahead. + +664 +00:55:39,090 --> 00:55:41,070 +Yeah. I was gonna say it's one of those projects that makes me want to + +665 +00:55:41,570 --> 00:55:45,110 +set up a. Like a vapor server or something just to do some Swift on + +666 +00:55:45,610 --> 00:55:48,150 +the server. Yeah. Or just like, + +667 +00:55:48,870 --> 00:55:52,470 +I wonder if there's like, something you do on a pie, like just + +668 +00:55:52,970 --> 00:55:55,350 +hook it up to a CloudKit database. Like, there's a lot you could do here + +669 +00:55:55,850 --> 00:55:57,510 +because all you need is decent os. + +670 +00:55:58,950 --> 00:56:02,110 +I don't know anything about sharing. I haven't done anything with sharing yet, + +671 +00:56:02,610 --> 00:56:05,180 +so I still have to do that and a few other things, but. No, + +672 +00:56:05,680 --> 00:56:08,940 +yeah, it's an interesting idea. + +673 +00:56:09,900 --> 00:56:12,860 +Thank you. Yeah. Well, thank you for joining, + +674 +00:56:13,360 --> 00:56:17,340 +Josh. Yeah. Thanks for hosting this and sharing this info. It's nice. + +675 +00:56:18,060 --> 00:56:20,780 +Yeah. If you ever run into anything, let me know. + +676 +00:56:21,420 --> 00:56:24,780 +Will do. All right, talk to you later. All right, + +677 +00:56:25,280 --> 00:56:26,700 +sounds good. See you. Bye. + diff --git a/docs/transcriptions/transcript.txt b/docs/transcriptions/transcript.txt new file mode 100644 index 00000000..408179fe --- /dev/null +++ b/docs/transcriptions/transcript.txt @@ -0,0 +1,177 @@ +Speaker A: Hey, Evan, can you hear me all right? + +Speaker B: Yeah, I can hear you. + +Speaker A: Awesome. How do I sound? Good. I've used this microphone in ages. It's like all dusty. How you think I should wait like five minutes for people to come in or. + +Speaker B: Probably. Yeah, that there's if. Yeah, otherwise you can just. You could start, but that'll be interesting. + +Speaker A: Do you mind if I grab a cup of coffee real quick? + +Speaker B: No, not at all. + +Speaker A: Not at all. Okay, cool. I'm not using the AirPods mic, so I can hear you, but you won't be able to hear me. + +Speaker B: Okay. + +Speaker A: It's. Thank you for your patience. So is it just you? + +Speaker B: It looks like it's just me. Josh is trying to get in, but he's trying to get on on his mobile device and I don't think that's possible with Riverside. + +Speaker A: Surprised? I mean, I know they have an app. + +Speaker B: Maybe he's using. I'm not sure if he's using. Using the app or not. + +Speaker A: Okay. Should I just go? + +Speaker B: Sure. + +Speaker A: Okay. Well, thanks for joining me, Evan. I really appreciate it. I would say no. I mean I do, seriously. So yeah, this is a kind of a dry run. I would say I'm about 60% done with this presentation about CloudKit on the server and we'll probably hop back and forth between Keynote and not Keynote, but yeah. So this is CloudKit as your backend from iOS to server side Swift. So what is CloudKit? CloudKit is a service launched by Apple probably a decade ago to kind of give developers a built in back end for storing data for their apps. One of the biggest benefits is is how cheap it is to use for iOS developers. So if you have built an app, you could just add CloudKit right here within the Xcode project and use the regular CloudKit API in Swift to go ahead and start using it in your app. Here is what it looks like to create a new record type. You can do all this through the CloudKit dashboard. In CloudKit you could also do this using a schema file too. And you can export and import your schema that way. And it's not a SQL based database, it's much more, no sequel ish or an abstract layer above it. But essentially you can create records kind of like a table but not quite in your records. You can create a struct for it. You can just use CloudKit directly to go ahead and then you can then plug it into your app and do fun stuff like this. We can do things like queries and basic database stuff. There's a lot of advantages to it. For one, if you're doing Apple only, then it definitely makes sense to look into, at least look into CloudKit. If you're just going to deploy to Apple Devices. If you don't mind the, the fact that it's not a regular SQL database, that's something too to think about. If you like need a SQL database, this might not be what you want. And then if you don't mind working with a lot of the abstraction layers that CloudKit provides, then this might be good for you to get started or especially if you don't have any database experience. So as far as like server choices, I would say CloudKit might not be your first choice, but it certainly is a decent choice if you're going the Apple only route. But then the question comes in, why would you want Cloud server side CloudKit? Why would you want to do anything with CloudKit on the server? So here's, here's the first case. Well, this is how you can go ahead and do that is they provide actually a REST API for calls to CloudKit using the, if you go to the documentation, I'll provide a link to that CloudKit Web Services which provides a lot of the documentation for what we'll be talking about today. A lot of this is abstracted out in the JavaScript library. So if you want to do stuff on a website, they provide a CloudKit JavaScript library for that. Sorry, just going into do not disturb mode. They even in that web references documentation they provide a composing web service request and all these instructions about how to go ahead and do that. So man, was it like half a decade ago that I built Heart Twitch and at the time I don't think there was anything, there was anything like sign in with Apple even. And like I really didn't want like to explain how harshwitch works is you have like a watch and it will send the heart rate to the server and then the server will then use a web socket to push it out to a web page. And then you would point OBS or some sort of streaming software to the URL or to the browser window and then that way you can stream your heart rate. That's how it works. And what I really didn't want is a difficult way for a user to log in with a username and password on the watch because we all know typing on the watch is hell. So my, my thought was like, and I didn't have sign in with Apple, right? So my thought was why don't we use CloudKit? Because you're already signed in a CloudKit on the Watch with your, your id. And what you do is you log in with a regular like email address and password in Heart Twitch on the website. And then there's a little, there's a site, there's a part of the site where you can sign into CloudKit and then from there you can, because, because of the CloudKit JavaScript library, you can then I can then pull the all the devices because when you first launch the app on the Watch, it adds your watch to the CloudKit database. And then I could pull that in and then add that to my postgres database. So then there is no need for authentication because I already have the CloudKit, the device added in my postgres database. So it's kind of like knows, oh yeah, this is Leo's watch, he doesn't need to authenticate. And that way we can link devices to accounts without having to do any sort of login process. And so this was my use case for doing server side. Essentially CloudKit was I could call the CloudKit web server based on that person's web authentication token, which we'll get all into later. I then pull that information in. So. Cool. Just checking if anybody's having issues. It doesn't look like it. So that's good to know. So that was the private database piece, but I actually think a much more useful case would be the public database because the idea would be is that you'd have some sort of app that would use central repository of data that it can pull information from. And I'm looking at both of these with Bushel and then an RSS reader I'm building called Celestra with Bushel. The. The way it's built right now is I have this concept of hubs and you can plug in a URL and that URL would provide or some sort of service. That service would then provide the Entire List of macOS restore images that are available. But then I realized like really there's only one location for those and each service is just going to be using the same URLs anyway. So if I had one central repository or one central database because they all pull from Apple, I can then parse the web for those restore images and then store them in CloudKit and then that way Bushel can then pull those from one single repository. And all I would have to do, and what I'm doing now is running basically a GitHub action or you could do like a Cron job where it would run on Ubuntu, wouldn't even need a Mac and it would download and scrape the web for restore images and storm in the public database. It's the same idea with Celestra. It's an RSS reader. What if I took those RSS RSS files in the web and just scrape them and then store them in a CloudKit database in a public database and then that way people can pull that up all through CloudKit. So the idea today is we're going to talk about how to set something, how I set something like this up and how you could use use my library to then go ahead and do this yourself for any sort of work that you're going to do that where you want to use either a public or private database in CloudKit. So this is where I introduce myself. So I'm going to talk today about building Miskit, which is my library I built for doing CloudKit stuff on the server or essentially off of, not off of Apple platforms. Evan, do you have any questions before I keep going? + +Speaker B: No, it's good. Good topic though. + +Speaker A: So like I said, we have CloudKit Web Services and CloudKit Web Services. We provide a lot of documentation. We talked about CloudKit JS and the instructions on how to compose a web service request which has everything I need to compose one. And back in 2020 I did this all manually. The thing is at this point, if you look at right there, actually if you look at the top, you can see it hasn't been updated in over 10 years, which is kind of crazy, but it works. And then we got introduced to something back in WWDC I want to say it was 23. We got introduced to the Open API generator which is really nice because then we have, we can generate the Swift code if we know what the Open API documentation looks like it. And of course Apple doesn't provide one for CloudKit but they did provide a pretty big piece open. If you ever you looked at the Open API generator, it's amazing. Takes the Open API gamble file and generates all the Swift code you need. One of the other issues I had with first developing Miskit in 2020 was that there was no way to like there was no abstraction layer which could differentiate between doing something on the server or using regular like URL session which is more targeted towards client side. So I had to build my own abstraction for that. Luckily Open API has, there's open API transport I believe, which provides an abstraction layer where you can then plug in either use Async HTTP client, which is the server way of doing it, or you can plug in a URL session transport, which is of course the client way to do, provides a really great tutorial. I highly recommend checking this out as well as the doxy documentation that they provide. So this is great. But then I'd have to go ahead and I'd have to figure out a way to convert all this documentation into an open API document. I mean, can you guess what helped me to get build an open API document from all this documentation? + +Speaker B: Some of the tools, some AI tool. + +Speaker A: Yes. AI came and I'm like, holy crap. Like AI is really good at documenting your code, but it's also pretty darn good at taking documentation and building code. So then I would just plug it. I've been plugging in with Claude and it has a copy of all the documentation in my repo and it can go ahead and edit the open API. It's not perfect by any means, of course, but that's what unit tests are for. And actually having integration tests in order to do stuff so that. Sorry, I just want to make sure nothing important. I hate teams. Okay, so great. So let's talk about. Sorry, slides are still not done, but let's talk about authentication methods. You can see I have the logos here, but I haven't quite cleaned this up. So there's really two and a half authentication methods when it comes to CloudKit. So here is the miss demo database. You just go in here and you can go to tokens and keys and then that will give you access to set up either the API if you want to do API key or API token if you want to do a private database or a server to server keyset if you want to do a public database. So let's talk about the API token. Pretty simple. You just go into here, click the plus sign, you say a name and you say whether you want to do a post message or URL redirect. We'll get into that in a little bit in the next section. And then whether you want to have user info and you click save and you'll get a nice little API token you could use in your web your web calls essentially. API doesn't really. The API token doesn't really give you a lot of. But what it does give you is it gives you an entry to get a web authentication token for a user. So basically the way that works. So you'll notice here, when we were in this section, we have this piece here called Sign in Callback. So you can have either call a JavaScript, it's called a message event, it will call a Message event and a message event will have the metadata with the web authentication token of that user. Or you could do URL redirect where on authentication the user has a URL and then part of that URL is then having part of one of the query parameters and we'll get into that. We'll then have the web authentication token in the URL. So you put, basically you have your website, you add the JavaScript, you need to add the sign in with Apple. Oh, here's Josh. Oh cool. Josh, you there? + +Speaker C: I hope so. + +Speaker A: Good. Okay. Hey, we were just talking about how to set up. I'm going to go back a little bit Evan, but not too far back. + +Speaker B: Yeah, no worries. + +Speaker A: That's okay. But we talked about setting up API token and how to do that. So you go in here, you just click plus, you select your sign in callback and you put in a name and it'll give you an API token once you click save. Basically. Come on. The reason you want an API token is this allows you to then have users Sign in to CloudKit either using, using the the web service like Curl or you could also do it through a website using CloudKit js. So web authentication token we talked about how you can either do the post message or you can do the URL redirect. Basically you have the JavaScript on your website and there has a button, click the button, you get this nice little window here sign in and then when you sign in if you had selected post message, you'll get the web authentication token and the data of the event in JavaScript or you will get the web authentication token as a URL in the callback URL here. Does that make sense? + +Speaker B: Yep. + +Speaker A: Yeah. In some cases if you scour the Internet so Stack overflow will tell you and this has happened to me sometimes it will not be CK web authentication token, sometimes it'll be CK session because that's what Apple likes to do. But it's the same thing. So you basically want to look for either property or query parameter name and you should be good to go and then you'll have that user as well authentication token you could do. What I, what I've been doing is, is I've been take like making a call to a like local server for instance and then essentially then I could do whatever I want with that web authentication token. As long as you have the web authentication token and the API token you can do anything on a private database that the user has rights to. So you can go, you can go to town with that all this stuff gets Swift in a cookie too. So that way it'll work. When you go back, if you have checked the box for allow, it's either a box or JavaScript method property that will say, hey, I want this to persist. It'll be Swift in a, in a cookie as well. So if you want to spelunk your cookies, you can see the web authentication token there. So that's actually the easier of the two. So that gives you the private database for the public database is where you're going to need a server to server authentication. And so to do that it's really actually not as bad as I thought it was going to be. But you go to the new server to server key, put in a name you want, it'll actually give you the command you need to run and then you just paste in the public key in here. That gives you. That will give you everything you need. So here's how to run it. Basically, sorry about that. We just run that. That gives us the key. We can go ahead and get the public key. We can also pipe it to PB Copy and then all we have to do is paste that in the box over here. There we go. It's pretty complicated to use the server key. We can spell on the miskit code on how to do it because it does a lot of that work for you if you have it. But you will need the, the private key, the key id, I think, I think that's it. And then you should be good with having access now to the public database. So just to go over, there's differences between the public and private database. So this is query. You can see my cursor, right? Query and lookup of records is available on all but file changes or, excuse me, record changes. It's not available on public zones, aren't really available in public zone changes aren't available in public notifications. Zone notifications aren't available in public, but query notifications are. And you can also do any stuff with assets which are basically binary files. You can also do that in all of them. You can't do query notifications on shared. Shared would essentially work like private essentially. So it's just a matter of who. Who's the owner and how is it shared. So one of the big challenges I think we've all faced this when we've dealt with certain web services is field type polymorphism. If you've done JSON where you don't know what type you're getting back or what data you're getting back, this can Be a bit challenging. So if you look at the documentation in Web Services Reference, there is a, there's a page called types and dictionaries and there is types. There's different type values for each field. If you're familiar with CloudKit, you've seen this, right? So you have an asset which is basically a, a binary file. You have bytes which is essentially a 60 byte base 64 encoded string, date type which is returned as a number. Double is returned as a number because These are the JavaScript types. Int is returned as a number and then there's location reference and then string and list. And how would you like, how do you do adjacent object like this? How would you even represent this in Swift? Because you don't know what type you're going to get. So like I said, this is a work in progress. Sorry. So what I do, I don't know how much you can see this. I'm going to actually move over to my documentation here at this point. So how are we doing on time? We good? + +Speaker B: Yeah, I think, I think we're doing good. + +Speaker A: Okay, cool. Any, do you want to ask questions? + +Speaker B: I don't have anything right now. + +Speaker C: Same nothing right now. But this seems applicable to things I'll be doing coming up. + +Speaker A: Okay, cool. So we have set up in the open. So we have an open API YAML file that you can pull up in Miskit, which is basically every like the documentation converted to YAML. And so what we do is you can set up in the YAML the field value requests and they have an enum type essentially for, for open API. So and then, so this has, you know, it could be one of either any of these types of. And then there's an enum in case you have a list. So if you have a list value type there is an extra property called type and then that will tell you what type the. The list is. And it's homo homomorphic. It's all the same list type. You can't have lists of different types. And then we have here again field value. Sometimes the type is available, sometimes it's not. But basically we have all the different value types available to us in a CK value. And then this is. Then the Open API generator essentially builds this for me which is. Has an enum and a struck for field field value request and then it does all the decoding for me. Thankfully I didn't have to do any of it. And then yeah, I just wanted to cover that piece where we show how we deal with these kind of like polymorphic types and how those work. The next thing I want to cover is error handling. So if you look at the documentation gives you. If you get an error we get something like this and then that will show you in the. In the table actually shows you what each error means. So again we do like an enum in YAML. It's basically a string and then we have everything else be a string. And then the open API generator will automatically generate this which gives us the server error code and the error response. It'll also do all this stuff here, which is really nice. And then we've then in our. We've abstracted a lot of this in miskit. So that way we also have now a cloud cloud error type which gives us a lot more info regarding that. So that's how we handle errors. And everything I do in the abs, the more abstract higher up stuff is done using type throws like I have type throws and everything. So that's how I handle that. Let me check one last piece I wanted to cover. The last piece I want to cover is really cool. And that is the authentication layer. So Open API provides what's called middleware and that allows you to, when you create a client or a server, you can plug that in and it will handle like let's say you need to make modifications with the request or response. When it comes in, you can intercept it and make whatever modifications you want to make. And in this case what we've done is I've created an authentication middleware which then sees if you have what's called a token manager and an authentic you have that and an authentication method. And the way it works is you pick what type of authentication you want to use. If you already have like a pre existing web token or you already have, or you, you know, have your key ID and your private key already, or you just have the API token. We've created basically a middleware that uses that. So this is how it creates the headers for server to server. So it does all this for us. And then what I added, which I think is really nice, is called the adaptive token manager. And the idea with that is like let's say you're using a client and you have the web authentication token now and then this allows you to upgrade with that web authentication token to the private database and have access to that. So and then all the, all the signing is done before you in miskit for the server to server because stuff that needs to be signed, etc. And it takes care of all that. All stuff that Claude was essentially able to decipher from the documentation. There's one more thing I wanted to show. If you want to hop in with a question while I pull something up, feel free. No questions. Cool. So I'm going to show one last thing and that is how do we actually deploy this? Is this too big, too small? Looks okay. + +Speaker C: That looks good. + +Speaker B: Yeah, it looks good. + +Speaker A: Okay, cool. So essentially what I've done is I'm using GitHub Actions. There's a way you can. This is all public by the way, so I will provide URLs in the Slack or something. Let's do this one. So this is a Swift package for Bushel. It's called Bushel Cloud. It pulls the stuff up from. Uses Miskit to go ahead and pull, get access to CloudKit and let me go back to the workflow. How familiar are you with GitHub workflows? + +Speaker C: Sadly not had the chance to work too deeply with them yet. + +Speaker A: Okay. Basically it's like for CI, but you can also set it up on a schedule. So I did that and then it runs the scheduled job and then I just execute. So then this was refactored over here into an action. There we go. And I have all sorts of stuff here for like this is generic essentially, but all these, the environment, etc. These are all passed from that workflow into here. These are basically either API keys or the information that I need for accessing Cloud, the public, public database. Right. And then I already pre built the binary. So we already have that. We're running this on Ubuntu because it's the default. Look at it. If there is no binary, it goes ahead and builds the binary for me. So that's what this is doing. And then we make sure the binary works. We make, we make it executable, we validate, make sure all the API secrets are there. We then go ahead and this validates the pim. But essentially this is the fun part. We go ahead, we have all our inputs for the private key, the key id, environment, container id. And then I use Virtual Buddy for signing verification. And. It then goes in and it runs the sync and then we'll go in. Basically it pulls from several websites information about macrosos, restore images and checks whether they're signed. And then it goes ahead and it adds those to the database. And then what this does is it exports the information in a run. Let's, let's take a look, see if I have one. I can show you. Oh, there's one scheduled. Yeah, here we go. So there's 57 new restore images created, 177 updated. 234 total. No operations failed. I also store Xcode versions and Swift versions. Those get stored as well. Had to rebuild it, but here is the results. I'm not going to pull that up, but it's essentially updated my CloudKit database and that's all in the public database. And then maybe even by the time I present this, I'll have a working example in Bushel with that example working, which would be awesome. Celestra, same idea. So this looks like it was a RSS update. We get the workflow file and. Oh, sorry, I should point out, because you're probably wondering where is all these. The stuff all these secrets stored? Yes, they are stored in Actions secrets right here. So we have our private key ID API key from Virtual Buddy. So that's all stored there. Here is Celestra. It's for updating RSS feeds. So it just basically goes through. You can look at the Swift code it goes through, pulls RSS feeds and updates them into a CloudKit record or what do you call it? Yeah, record type. And I of course try to do it in such a way not to hammer people, but same idea, yeah, it goes ahead and it runs the binary it updates and then I also have like actual parameters that I take to to filter out, like which RSS feeds are high priority and which ones aren't based on the audience and etc. So yeah, so that's deployment. That's how you can get that working. There's weird stuff with cloud with GitHub that I've noticed. If you haven't updated it in a while, it doesn't run these cron jobs. So I need to figure out a how to get around it or find another service to do it. This is all free because it's public and it is running on Ubuntu. So that's really great. And the storage on CloudKit is dirt cheap, which is even more awesome. Sorry, let's see what else. I just want to make sure I covered all my slides. The last thing I'm going to talk about is just what are my plans? Excuse me. So I don't know if you check. Follow me. But I just released. I just released Alpha 5 that has lookup zones, fetch, record changes and upload assets. Upload the assets is pretty awesome. When I saw that work because I was like, cool, I can actually upload a binary to CloudKit, which is awesome. We got query filters to work for in and not in, so you could do that I have plans to continue working on this because I think there's a big future for something like this for a lot of people. Yes, you can technically use this in Android or Windows because the Swift thing does compile in Android and Windows. You can see I already added support for that. This is the support I recently had. And then we're. I'm just kind of like going through each of these because as great as AI is, it's not perfect. So we're just kind of going through these piece by piece with each version and hammering these away and then this is actually done. I don't even know why that's there. But yeah, I think system field integration might already be there and there's a few other things. Eventually I'd like to add support. So there, there's a whole API for CloudKit schema management that I could. That would be awesome if I could figure out how to do that. If I could figure out how to do key path query filtering, that would be fantastic. And yeah, but there's a. I mean the basics is there as far as if you want to do anything with a record, it's pretty much there. One thing with Celestra is I'd love to be able to do like test out subscriptions and see how that works. So yeah, that's really the bulk of my presentation today. Now is. Now it's time to ask me a ton of questions and make me feel dumb. Go for it. + +Speaker B: No, there's a lot there to. To absorb. But I, I like the concept and I know you've been working on this for a while and I always thought it was a pretty cool, pretty cool idea and implementation of this. + +Speaker A: Questions? + +Speaker C: So with something like. Accessing CloudKit through the web, is this setup more ideal for having your server do the authentication to CloudKit with Miskit or is miskit something that you could put into even like a client side, you know, like non Swift application or I guess not non Swift but like non like app application. I'm thinking in the context of like + +Speaker A: a. + +Speaker C: I guess if I wanted to create a something accessing CloudKit that is not your typical Mac or iOS app. + +Speaker A: Can you be more specific? + +Speaker C: I'm looking into one. One approach would be browser extensions. + +Speaker A: So for like a non Safari browser. + +Speaker C: Yes. + +Speaker A: Yeah, this would be great. So basically the way you'd want that to work, like the sticky part to me would be getting the web authentication token. Other than that, like have at it. So I'm gonna, I'm gonna be devil's advocate. Why not just use the CloudKit JavaScript library. + +Speaker C: If it's an extension, my brain jumps to Swift first. + +Speaker A: Right. But it's the reason I'm asking that is like it's a, it's already a web extension. I would assume that is true. That it's 90 web based or JavaScript based. So that's where I'm just like, well, you may as well. Like, I would love. I don't want to. Like, I love tooting my own horn. Right. But like, like why not just. Unless you're. Unless you're like building a executable, I guess, or an app. Ish. + +Speaker C: And I guess another application for this would be doing CloudKit stuff server side and then providing my own API layer over it. + +Speaker A: Yep, yep. So that's. Yeah. Are we talking private database or public database? + +Speaker C: Private. + +Speaker A: So in that case, basically like you'd have to go the Hard Twitch route and you would have to provide a way to get their web authentication token, essentially, if that makes sense. And then store it in Postgres or whatever the hell you want to do. Like that's, that's the way I did it with Hard Twitch. But once you have that, you can do anything you want on the server with their private database, if that makes sense. + +Speaker C: It does. + +Speaker A: Yep. Yep. A couple of things I wanted to bring up, so let's take a look. So part of my other presentation is working, talking about cross platform automation type stuff. And the one issue I've run into is. So it basically builds on everything. Right now. I'm going to share something. + +Speaker B: Hey guys, I got to drop. But it was good presentation, Leo. Thank you. + +Speaker A: Yeah, yeah. If I have more questions, if you have any feedback, just hit me up on Slack. + +Speaker B: Sounds good. + +Speaker A: Cool, thank you. Thank you so much for helping me set this up. Yeah, talk to you later. + +Speaker B: Thank you. Bye bye. + +Speaker C: Yeah, so if you had something else to show, I'm happy to look for. I'm here for a few more minutes as well. + +Speaker A: Yeah, yeah, yeah. So I have the workflow working here and it does Ubuntu, it does Windows, it does Android. So all that stuff is available to you. I would never recommend using Miskit on an Apple platform for obvious reasons, like what's the point? + +Speaker C: True. + +Speaker A: Unless there's something special that I provide that CloudKit doesn't like, I don't get it. + +Speaker C: Right. + +Speaker A: But we have an issue. So I just started dabbling. I haven't really done anything with wasm, but I did definitely try. Like I added support for WASM in my, in my Swift build action. The thing about WASA is it does not provide. It doesn't have a transport available. So we talked about transports, I think. Did you hear about that part about the Open API generator and transports? + +Speaker C: I think I was coming in at that point. + +Speaker A: Okay. When you create a client, so underneath the client you have what's called a client transport. This is so underneath this client, this is an abstraction layer above. So this is not the right one. Where's the public one? But anyway, there is here CloudKit service maybe. Yeah, here we go. So the CloudKit service has a client and part of the client is being able to say what transport you use in Open API. And there's two transports available right now. One is, one is your regular URL session for clients, which. That makes sense. Right. And then there's the Async HTTP client which is typically used like Swift NEO based for servers. The thing is that neither of those are available in wasp. Do you know what WASM is? + +Speaker C: I have no experience with it, but yes. + +Speaker A: Okay. It's. It's the web browser. Right. So. So you really can't use Miskit in. In the. In WASM yet because there is no transport. Now having said that, why on earth would you use. Awesome. Why would you use Miskit in the browser? Why not just use CloudKit js? So that's essentially, you know, What other questions do you have? + +Speaker C: My brain is mushy right now, so + +Speaker A: because of my presentation or because other + +Speaker C: things, I got two hours of sleep. + +Speaker A: Oh, I'm so sorry. + +Speaker C: So I'm following as best as I can. + +Speaker A: Snuggling. Yeah, the intro was basically how I originally built it for hard Twitch in 2020 for a private database login for the Apple Watch because I don't want to have a login screen. And so basically there's a way in the web browser to link your Apple Watch to your account and then from there you don't need to authenticate anymore. Nice. I built that all from hand and then in 23 they came out with the Open API generator which was like, oh wait, what if I can create an open API file out of Apple's 10 year old documentation? That'd be a lot of work, but I could do it. And I don't know if you heard, but there was this thing that came out a couple years ago called AI and it's really good at creating documentation for your code, but it's also really good at creating code for your documentation. And so I was like, oh yeah, this is great. Like I can just, I can just Feed it the documentation and go from there. And, like, basically, I've been going step by step through. Like I said, if you looked at the miskit repo, like, I'm going through step by step and adding new APIs based on what's available in the documentation, piece by piece. And I would say at this point, it's like most of the really, like 80% of that people use is there. There's like, stuff like subscriptions and zones that I'm still trying to figure out, but it's. It's pretty close to done at this point. + +Speaker B: Mm. + +Speaker A: If you use it. + +Speaker C: Yeah, it's one of those. + +Speaker A: Because I. Go ahead. + +Speaker C: Yeah. I was gonna say it's one of those projects that makes me want to set up a. Like a vapor server or something just to do some Swift on the server. + +Speaker A: Yeah. Or just like, I wonder if there's like, something you do on a pie, like just hook it up to a CloudKit database. Like, there's a lot you could do here because all you need is decent os. I don't know anything about sharing. I haven't done anything with sharing yet, so I still have to do that and a few other things, but. No, yeah, + +Speaker C: it's an interesting idea. + +Speaker A: Thank you. + +Speaker B: Yeah. + +Speaker A: Well, thank you for joining, Josh. + +Speaker C: Yeah. Thanks for hosting this and sharing this info. It's nice. + +Speaker A: Yeah. If you ever run into anything, let me know. Will do. All right, talk to you later. All right, sounds good. + +Speaker C: See you. + +Speaker A: Bye. + +Speaker C: Bye. \ No newline at end of file diff --git a/docs/transcriptions/transcript.vtt b/docs/transcriptions/transcript.vtt new file mode 100644 index 00000000..0ac16ceb --- /dev/null +++ b/docs/transcriptions/transcript.vtt @@ -0,0 +1,2023 @@ +WEBVTT + +04:22.980 --> 04:25.700 +Hey, Evan, can you hear me all right? Yeah, I can hear you. + +04:26.420 --> 04:28.740 +Awesome. How do I sound? Good. + +04:30.260 --> 04:33.580 +I've used this microphone in ages. It's like + +04:34.080 --> 04:34.420 +all dusty. + +04:41.140 --> 04:44.100 +How you think I should wait like five minutes for people to come in or. + +04:44.260 --> 04:47.530 +Probably. Yeah, that there's if. Yeah, + +04:48.010 --> 04:51.930 +otherwise you can just. You could start, but that'll be interesting. + +04:52.430 --> 04:54.570 +Do you mind if I grab a cup of coffee real quick? No, not at + +04:55.070 --> 04:58.930 +all. Not at all. Okay, cool. I'm not using the AirPods mic, + +04:59.430 --> 05:02.250 +so I can hear you, but you won't be able to hear me. Okay. + +06:02.440 --> 06:27.820 +It's. + +08:51.699 --> 08:55.060 +Thank you for your patience. + +09:09.010 --> 09:12.130 +So is it just you? It looks like it's + +09:12.630 --> 09:15.970 +just me. Josh is trying to get in, but he's trying to get on on + +09:16.470 --> 09:19.250 +his mobile device and I don't think that's possible with Riverside. + +09:23.250 --> 09:26.130 +Surprised? I mean, I know they have an app. + +09:27.590 --> 09:30.070 +Maybe he's using. I'm not sure if he's using. Using the app or not. + +09:35.190 --> 09:36.310 +Should I just go? + +09:38.230 --> 09:40.470 +Sure. Okay. + +09:42.390 --> 09:45.270 +Well, thanks for joining me, Evan. I really appreciate it. + +09:47.430 --> 09:49.910 +I would say no. I mean I do, seriously. + +09:51.830 --> 09:55.470 +So yeah, this is a kind of a dry run. I would say I'm about + +09:55.970 --> 10:00.990 +60% done with this presentation about CloudKit + +10:01.490 --> 10:05.470 +on the server and we'll probably + +10:05.970 --> 10:09.990 +hop back and forth between Keynote and not Keynote, but yeah. + +10:11.670 --> 10:14.870 +So this is CloudKit as your backend from iOS + +10:15.030 --> 10:16.630 +to server side Swift. + +10:27.600 --> 10:31.200 +So what is CloudKit? CloudKit is a service + +10:32.240 --> 10:36.279 +launched by Apple probably a decade ago to + +10:36.779 --> 10:40.200 +kind of give developers a + +10:40.700 --> 10:43.680 +built in back end for storing data for their apps. + +10:44.480 --> 10:47.890 +One of the biggest benefits is is how cheap it is + +10:47.970 --> 10:49.970 +to use for iOS developers. + +10:52.450 --> 10:55.690 +So if you have built + +10:56.190 --> 10:59.970 +an app, you could just add CloudKit right here within + +11:01.330 --> 11:05.690 +the Xcode project and use + +11:06.190 --> 11:09.530 +the regular CloudKit API in Swift to go ahead and + +11:10.030 --> 11:10.850 +start using it in your app. + +11:13.390 --> 11:16.990 +Here is what it looks like to create a new record type. + +11:17.490 --> 11:20.190 +You can do all this through the CloudKit dashboard. + +11:24.190 --> 11:27.430 +In CloudKit you could also do this using a + +11:27.930 --> 11:31.710 +schema file too. And you can export and import your schema + +11:32.210 --> 11:36.030 +that way. And it's not a SQL based database, + +11:36.530 --> 11:39.910 +it's much more, no sequel ish or an abstract layer + +11:40.410 --> 11:44.120 +above it. But essentially you can create records + +11:44.520 --> 11:48.200 +kind of like a table but not quite in your records. + +11:49.400 --> 11:52.680 +You can create a struct for it. + +11:53.180 --> 11:57.039 +You can just use CloudKit directly to go ahead and then + +11:57.539 --> 12:00.520 +you can then plug it into your app and do fun stuff like this. + +12:01.560 --> 12:05.280 +We can do things like queries and basic + +12:05.780 --> 12:09.760 +database stuff. There's a lot of advantages to it. For one, + +12:10.080 --> 12:12.640 +if you're doing Apple only, + +12:13.600 --> 12:16.800 +then it definitely makes sense to look into, at least + +12:17.300 --> 12:18.080 +look into CloudKit. + +12:22.320 --> 12:25.440 +If you're just going to deploy to Apple Devices. + +12:26.080 --> 12:28.720 +If you don't mind the, + +12:29.920 --> 12:32.640 +the fact that it's not a regular SQL database, + +12:34.050 --> 12:37.050 +that's something too to think about. If you like need a SQL database, this might + +12:37.550 --> 12:40.770 +not be what you want. And then if you don't mind working + +12:41.270 --> 12:44.610 +with a lot of the abstraction layers that CloudKit provides, + +12:46.930 --> 12:50.730 +then this might be good for you to get started or especially + +12:51.230 --> 12:52.450 +if you don't have any database experience. + +12:54.130 --> 12:57.970 +So as far as like server choices, I would say CloudKit + +12:58.470 --> 13:01.970 +might not be your first choice, but it certainly is a decent choice + +13:02.290 --> 13:04.450 +if you're going the Apple only route. + +13:09.970 --> 13:13.050 +But then the question comes in, why would you want Cloud server side + +13:13.550 --> 13:16.610 +CloudKit? Why would you want to do anything with CloudKit on the server? + +13:17.970 --> 13:20.290 +So here's, here's the first case. + +13:20.690 --> 13:24.330 +Well, this is how you can go ahead and do that is they + +13:24.830 --> 13:27.880 +provide actually a REST API for calls to CloudKit + +13:28.910 --> 13:32.830 +using the, if you go to the documentation, I'll provide a link to that + +13:32.910 --> 13:36.990 +CloudKit Web Services which provides + +13:37.490 --> 13:41.270 +a lot of the documentation for what we'll be talking about today. A lot + +13:41.770 --> 13:44.790 +of this is abstracted out in the JavaScript library. So if you want to do + +13:45.290 --> 13:49.390 +stuff on a website, they provide a CloudKit JavaScript + +13:50.270 --> 13:53.710 +library for that. Sorry, + +13:56.190 --> 13:59.230 +just going into do not disturb mode. + +14:07.950 --> 14:11.710 +They even in that web references documentation they provide a + +14:12.210 --> 14:15.670 +composing web service request and all these instructions about how to go ahead and + +14:16.170 --> 14:20.110 +do that. So man, was it like half a decade ago + +14:20.880 --> 14:24.880 +that I built Heart Twitch and + +14:25.360 --> 14:28.080 +at the time I don't think there was anything, + +14:30.080 --> 14:33.840 +there was anything like sign in with Apple even. And like + +14:34.340 --> 14:38.480 +I really didn't want like to + +14:38.980 --> 14:42.600 +explain how harshwitch works is you have like a watch and it will send + +14:43.100 --> 14:47.180 +the heart rate to the server and then the + +14:47.680 --> 14:51.100 +server will then use a web socket to push it out to a web page. + +14:52.060 --> 14:55.260 +And then you would point OBS or some sort of + +14:55.760 --> 14:58.860 +streaming software to the URL or to the browser window and then that way you + +14:59.360 --> 15:02.900 +can stream your heart rate. That's how it works. And what I really didn't want + +15:03.400 --> 15:07.500 +is a difficult way for a user to log in with a username + +15:08.000 --> 15:11.260 +and password on the watch because we all know typing on the watch is hell. + +15:11.900 --> 15:15.600 +So my, my thought was like, and I didn't have sign + +15:16.100 --> 15:19.680 +in with Apple, right? So my thought was why don't we use CloudKit? + +15:19.840 --> 15:23.120 +Because you're already signed in a CloudKit on the Watch with + +15:23.620 --> 15:27.080 +your, your id. And what + +15:27.580 --> 15:31.520 +you do is you log in with a regular like email address + +15:32.020 --> 15:34.960 +and password in Heart Twitch on the website. + +15:35.840 --> 15:38.920 +And then there's a little, there's a site, there's a part of the site where + +15:39.420 --> 15:42.740 +you can sign into CloudKit and then from + +15:43.240 --> 15:46.260 +there you can, because, + +15:46.760 --> 15:52.580 +because of the CloudKit JavaScript library, you can then I can then pull the all + +15:53.080 --> 15:55.740 +the devices because when you first launch the app on the Watch, it adds your + +15:56.240 --> 15:59.540 +watch to the CloudKit database. And then I could pull that in + +16:00.040 --> 16:03.380 +and then add that to my postgres database. So then there is no need for + +16:03.880 --> 16:06.740 +authentication because I already have the CloudKit, + +16:07.720 --> 16:11.120 +the device added in my postgres database. So it's kind of like + +16:11.620 --> 16:15.520 +knows, oh yeah, this is Leo's watch, he doesn't need to authenticate. + +16:16.020 --> 16:19.440 +And that way we can link devices to accounts without having to do any + +16:19.940 --> 16:23.320 +sort of login process. And so this was my use case for + +16:23.800 --> 16:27.720 +doing server side. Essentially CloudKit + +16:28.220 --> 16:33.610 +was I could call the CloudKit web server based + +16:34.110 --> 16:37.730 +on that person's web authentication token, which we'll get all + +16:38.230 --> 16:40.370 +into later. I then pull that information in. + +16:42.050 --> 16:42.450 +So. + +16:47.250 --> 16:47.730 +Cool. + +16:50.770 --> 16:55.050 +Just checking if anybody's having issues. It doesn't look like it. So that's + +16:55.550 --> 16:59.090 +good to know. So that was the private database piece, + +16:59.950 --> 17:03.510 +but I actually think a much more useful case would be the + +17:04.010 --> 17:07.550 +public database because the idea + +17:08.050 --> 17:11.950 +would be is that you'd have some sort of app that would use central + +17:12.450 --> 17:15.950 +repository of data that it + +17:16.450 --> 17:19.710 +can pull information from. And I'm looking at both of these with + +17:19.950 --> 17:24.510 +Bushel and then an RSS reader I'm building called Celestra with + +17:25.010 --> 17:28.479 +Bushel. The. The way it's built right now is I have + +17:28.979 --> 17:32.639 +this concept of hubs and you can plug in a URL + +17:33.139 --> 17:36.999 +and that URL would provide or some sort of service. That service + +17:37.499 --> 17:41.479 +would then provide the Entire List of macOS restore images that + +17:41.979 --> 17:45.319 +are available. But then I realized like + +17:45.819 --> 17:48.919 +really there's only one location for those and each service is just going + +17:49.419 --> 17:52.850 +to be using the same URLs anyway. So if I had one + +17:53.350 --> 17:57.170 +central repository or one central database because + +17:57.670 --> 18:01.690 +they all pull from Apple, I can then parse the web for those + +18:02.190 --> 18:06.010 +restore images and then store them in CloudKit and then that way Bushel + +18:06.510 --> 18:09.970 +can then pull those from one single repository. + +18:10.210 --> 18:13.850 +And all I would have to do, and what I'm doing now is running basically + +18:14.350 --> 18:17.450 +a GitHub action or you could do like a Cron job where it would run + +18:17.950 --> 18:21.290 +on Ubuntu, wouldn't even need a Mac and it would download and scrape the + +18:21.790 --> 18:24.430 +web for restore images and storm in the public database. + +18:26.350 --> 18:29.870 +It's the same idea with Celestra. It's an RSS reader. What if I took + +18:30.370 --> 18:33.950 +those RSS RSS files in the + +18:34.450 --> 18:38.430 +web and just scrape them and then store them in a CloudKit database in + +18:38.930 --> 18:42.110 +a public database and then that way people can pull that up all through + +18:42.610 --> 18:46.550 +CloudKit. So the idea today + +18:47.050 --> 18:50.550 +is we're going to talk about how to set something, how I set something like + +18:51.050 --> 18:54.460 +this up and how you could use use my library to + +18:54.960 --> 18:57.860 +then go ahead and do this yourself for any sort of work that you're going + +18:58.360 --> 19:01.500 +to do that where you want to use either a public or private database in + +19:02.000 --> 19:05.060 +CloudKit. So this is where I introduce myself. + +19:05.940 --> 19:09.300 +So I'm going to talk today about building Miskit, which is my library + +19:09.800 --> 19:13.180 +I built for doing CloudKit stuff on the + +19:13.680 --> 19:17.140 +server or essentially off of, not off of Apple platforms. + +19:19.770 --> 19:23.130 +Evan, do you have any questions before I keep going? No, + +19:23.370 --> 19:24.890 +it's good. Good topic though. + +19:26.810 --> 19:31.090 +So like I said, we have CloudKit Web Services and CloudKit + +19:31.590 --> 19:35.330 +Web Services. We provide a lot of documentation. We talked about CloudKit + +19:35.830 --> 19:39.570 +JS and the instructions on how to compose a web service request + +19:40.070 --> 19:43.730 +which has everything I need to compose one. And back in 2020 I did + +19:44.230 --> 19:47.240 +this all manually. The thing is + +19:47.740 --> 19:50.040 +at this point, if you look at right there, + +19:51.000 --> 19:53.800 +actually if you look at the top, you can see it hasn't been updated in + +19:54.300 --> 19:58.120 +over 10 years, which is kind of crazy, + +19:58.920 --> 20:03.240 +but it works. And then we got + +20:04.200 --> 20:07.400 +introduced to something back in WWDC I want to say it was + +20:07.480 --> 20:11.360 +23. We got introduced + +20:11.860 --> 20:16.360 +to the Open API generator which is really nice because then + +20:16.840 --> 20:20.080 +we have, we can generate the Swift code if we know what the + +20:20.580 --> 20:24.080 +Open API documentation looks like it. And of course Apple doesn't provide + +20:24.580 --> 20:29.639 +one for CloudKit but they did provide a pretty big piece open. + +20:29.800 --> 20:33.320 +If you ever you looked at the Open API generator, it's amazing. Takes the + +20:33.820 --> 20:37.560 +Open API gamble file and generates all the Swift code you need. + +20:37.880 --> 20:42.160 +One of the other issues I had with first developing Miskit + +20:42.660 --> 20:46.160 +in 2020 was that there was no way to like there + +20:46.660 --> 20:50.320 +was no abstraction layer which could differentiate between doing something on the server + +20:50.720 --> 20:54.040 +or using regular like URL session + +20:54.540 --> 20:56.080 +which is more targeted towards client side. + +20:58.960 --> 21:02.800 +So I had to build my own abstraction for that. Luckily Open API has, + +21:04.080 --> 21:07.720 +there's open API transport I believe, which provides an + +21:08.220 --> 21:12.100 +abstraction layer where you can then plug in either use Async HTTP + +21:12.600 --> 21:15.660 +client, which is the server way of doing it, or you can plug in a + +21:16.160 --> 21:19.580 +URL session transport, which is of course the client way + +21:20.080 --> 21:23.740 +to do, provides a really great tutorial. + +21:24.240 --> 21:27.740 +I highly recommend checking this out as well as the + +21:28.240 --> 21:30.020 +doxy documentation that they provide. + +21:31.860 --> 21:35.180 +So this is great. But then I'd have to go ahead and I'd + +21:35.680 --> 21:39.700 +have to figure out a way to convert all this documentation into an open + +21:40.200 --> 21:44.260 +API document. I mean, can you guess what + +21:44.760 --> 21:48.260 +helped me to get build an open API document + +21:48.760 --> 21:51.620 +from all this documentation? Some of the tools, + +21:52.659 --> 21:54.980 +some AI tool. Yes. + +21:56.820 --> 21:58.980 +AI came and I'm like, holy crap. + +21:59.460 --> 22:03.060 +Like AI is really good at documenting your code, but it's also pretty + +22:03.560 --> 22:06.250 +darn good at taking documentation and building code. + +22:06.890 --> 22:10.650 +So then I would just plug it. I've been plugging in with Claude + +22:11.050 --> 22:14.730 +and it has a copy of all the documentation in my repo and + +22:15.230 --> 22:18.810 +it can go ahead and edit the open API. It's not perfect by any means, + +22:19.310 --> 22:21.610 +of course, but that's what unit tests are for. + +22:23.850 --> 22:28.090 +And actually having integration tests in order to do stuff so + +22:31.460 --> 22:31.700 +that. + +22:35.380 --> 22:41.100 +Sorry, I just want to make sure nothing + +22:46.900 --> 22:48.020 +I hate teams. + +22:53.060 --> 22:56.420 +Okay, so great. So let's talk about. + +22:59.700 --> 23:05.380 +Sorry, slides are still not done, but let's talk about authentication + +23:05.880 --> 23:09.540 +methods. You can see I have the logos here, but I haven't quite cleaned this + +23:10.040 --> 23:14.140 +up. So there's really two + +23:14.640 --> 23:17.380 +and a half authentication methods when it comes to CloudKit. + +23:18.420 --> 23:21.950 +So here is the miss demo + +23:22.450 --> 23:26.070 +database. You just go in here and you can go to tokens and keys + +23:26.570 --> 23:30.550 +and then that will give you access to set up either the API + +23:31.050 --> 23:34.550 +if you want to do API key or API token if + +23:35.050 --> 23:38.750 +you want to do a private database or a server to server keyset if you + +23:39.250 --> 23:41.950 +want to do a public database. So let's talk about the API token. + +23:42.510 --> 23:45.870 +Pretty simple. You just go into here, click the plus sign, + +23:46.840 --> 23:50.240 +you say a name and you say whether you want to do a post + +23:50.740 --> 23:54.200 +message or URL redirect. We'll get into that in a little bit in the next + +23:54.700 --> 23:58.760 +section. And then whether you want to have user info + +23:58.840 --> 24:02.960 +and you click save and you'll get a nice little API token + +24:03.460 --> 24:06.680 +you could use in your web your web calls essentially. + +24:09.000 --> 24:12.260 +API doesn't really. The API token doesn't really give you a lot of. + +24:12.570 --> 24:15.330 +But what it does give you is it gives you an entry to get a + +24:15.830 --> 24:19.450 +web authentication token for a user. So basically the way that + +24:19.950 --> 24:22.490 +works. So you'll notice here, + +24:23.050 --> 24:24.890 +when we were in this section, + +24:27.050 --> 24:30.650 +we have this piece here called Sign in Callback. So you + +24:31.150 --> 24:34.530 +can have either call a JavaScript, it's called a message + +24:35.030 --> 24:38.730 +event, it will call a Message event and a message event will have the + +24:39.230 --> 24:42.650 +metadata with the web authentication token of that user. Or you + +24:43.150 --> 24:46.730 +could do URL redirect where on authentication the user + +24:46.970 --> 24:50.930 +has a URL and then part of that URL is then having part + +24:51.430 --> 24:55.010 +of one of the query parameters and we'll get into that. We'll then have the + +24:55.510 --> 24:57.050 +web authentication token in the URL. + +24:58.570 --> 25:02.130 +So you put, basically you have your website, you add + +25:02.630 --> 25:05.970 +the JavaScript, you need to add the sign in + +25:06.470 --> 25:08.010 +with Apple. Oh, here's Josh. + +25:14.310 --> 25:15.910 +Oh cool. Josh, you there? + +25:18.790 --> 25:21.590 +I hope so. Good. Okay. + +25:21.750 --> 25:24.429 +Hey, we were just talking about how to set up. I'm going to go back + +25:24.929 --> 25:27.910 +a little bit Evan, but not too far back. Yeah, no worries. + +25:27.990 --> 25:31.270 +That's okay. But we talked about + +25:31.770 --> 25:34.310 +setting up API token and how to do that. + +25:35.910 --> 25:39.110 +So you go in here, you just click plus, + +25:39.610 --> 25:43.150 +you select your sign in callback and you put in a name and it'll + +25:43.650 --> 25:46.310 +give you an API token once you click save. Basically. + +25:50.549 --> 25:51.190 +Come on. + +25:54.470 --> 25:58.830 +The reason you want an API token is this allows you to then have + +25:59.330 --> 26:03.060 +users Sign in to CloudKit either + +26:03.560 --> 26:06.700 +using, using the the + +26:07.200 --> 26:10.860 +web service like Curl or you could also do it through a + +26:11.360 --> 26:15.500 +website using CloudKit js. So web authentication + +26:16.000 --> 26:19.140 +token we talked about how you can either do the post message or you + +26:19.640 --> 26:23.020 +can do the URL redirect. Basically you have the JavaScript + +26:23.520 --> 26:27.020 +on your website and there has a button, click the button, you get this + +26:27.520 --> 26:31.140 +nice little window here sign in and + +26:31.640 --> 26:35.020 +then when you sign in if you had selected post message, + +26:35.340 --> 26:38.500 +you'll get the web authentication token and the data of + +26:39.000 --> 26:42.460 +the event in JavaScript or you will get the web authentication + +26:42.960 --> 26:46.140 +token as a URL in the callback URL here. + +26:46.780 --> 26:47.820 +Does that make sense? + +26:50.860 --> 26:54.220 +Yep. Yeah. In some cases + +26:54.380 --> 26:58.000 +if you scour the Internet so Stack overflow will tell you and this + +26:58.500 --> 27:02.360 +has happened to me sometimes it will not be CK web authentication token, + +27:02.860 --> 27:06.040 +sometimes it'll be CK session because that's what Apple + +27:06.540 --> 27:10.120 +likes to do. But it's the same thing. + +27:10.200 --> 27:13.840 +So you basically want to look for either property or query parameter + +27:14.340 --> 27:17.520 +name and you should be good to go and then you'll have that user as + +27:18.020 --> 27:20.680 +well authentication token you could do. + +27:20.920 --> 27:23.730 +What I, what I've been doing is, + +27:25.170 --> 27:28.690 +is I've been take like making a call + +27:29.190 --> 27:32.690 +to a like local server for instance and then essentially + +27:33.410 --> 27:36.690 +then I could do whatever I want with that web authentication token. As long as + +27:37.190 --> 27:40.730 +you have the web authentication token and the API token you can do anything on + +27:41.230 --> 27:44.050 +a private database that the user has rights to. + +27:44.450 --> 27:47.610 +So you can go, you can go to town with + +27:48.110 --> 27:51.420 +that all this stuff gets Swift in a cookie too. + +27:51.580 --> 27:55.260 +So that way it'll work. When you go back, if you + +27:55.500 --> 27:57.500 +have checked the box for allow, + +27:58.780 --> 28:01.940 +it's either a box or JavaScript method property that will say, + +28:02.440 --> 28:05.179 +hey, I want this to persist. It'll be Swift in a, in a cookie as + +28:05.679 --> 28:09.340 +well. So if you want to spelunk your cookies, you can see the web authentication + +28:09.840 --> 28:13.180 +token there. So that's actually the easier of the + +28:13.680 --> 28:16.940 +two. So that gives you the private database for the public database + +28:17.440 --> 28:19.820 +is where you're going to need a server to server authentication. + +28:21.340 --> 28:24.620 +And so to do that it's really actually not as + +28:25.120 --> 28:27.980 +bad as I thought it was going to be. But you go to the new + +28:28.220 --> 28:32.020 +server to server key, put in a name you want, it'll actually give you + +28:32.520 --> 28:35.180 +the command you need to run and then you just paste in the public key + +28:35.680 --> 28:37.340 +in here. That gives you. + +28:38.780 --> 28:42.300 +That will give you everything you need. So here's how to run it. + +28:42.800 --> 28:44.630 +Basically, sorry about that. + +28:57.190 --> 28:59.510 +We just run that. That gives us the key. + +29:00.710 --> 29:04.670 +We can go ahead and get the public key. We can also pipe + +29:05.170 --> 29:08.510 +it to PB Copy and then all we have to do is paste that in + +29:09.010 --> 29:10.930 +the box over here. + +29:17.970 --> 29:18.690 +There we go. + +29:25.890 --> 29:28.770 +It's pretty complicated to use the server key. + +29:30.050 --> 29:33.610 +We can spell on the miskit code on how to do it because it + +29:34.110 --> 29:37.090 +does a lot of that work for you if you have it. But you will + +29:37.590 --> 29:41.170 +need the, the private key, the key id, + +29:42.290 --> 29:46.490 +I think, I think that's it. And then you should be good with + +29:46.990 --> 29:50.130 +having access now to the public database. + +29:50.850 --> 29:54.730 +So just to go over, there's differences between the public and private + +29:55.230 --> 29:59.090 +database. So this is query. + +29:59.570 --> 30:03.010 +You can see my cursor, right? Query and lookup of records + +30:03.510 --> 30:07.110 +is available on all but file changes or, + +30:07.610 --> 30:11.390 +excuse me, record changes. It's not available on public zones, + +30:11.890 --> 30:16.470 +aren't really available in public zone changes aren't available in public notifications. + +30:16.550 --> 30:18.870 +Zone notifications aren't available in public, + +30:19.670 --> 30:23.350 +but query notifications are. And you can also do + +30:23.850 --> 30:27.470 +any stuff with assets which are basically binary files. You can also + +30:27.970 --> 30:32.190 +do that in all of them. You can't do query + +30:32.690 --> 30:36.390 +notifications on shared. Shared would essentially work like private + +30:36.850 --> 30:40.530 +essentially. So it's just a matter of who. + +30:41.030 --> 30:42.610 +Who's the owner and how is it shared. + +30:44.690 --> 30:47.810 +So one of the big challenges I think we've all faced this + +30:48.310 --> 30:52.449 +when we've dealt with certain web services is field type + +30:52.949 --> 30:56.570 +polymorphism. If you've done JSON where you don't know what type you're getting back or + +30:57.070 --> 30:59.410 +what data you're getting back, this can Be a bit challenging. + +31:00.530 --> 31:04.490 +So if you look at the documentation in + +31:04.990 --> 31:08.290 +Web Services Reference, there is a, + +31:09.090 --> 31:13.170 +there's a page called types and dictionaries and there is types. + +31:14.050 --> 31:17.890 +There's different type values for each field. If you're familiar with CloudKit, you've seen + +31:18.390 --> 31:22.610 +this, right? So you have an asset which is basically a, + +31:24.290 --> 31:28.210 +a binary file. You have bytes + +31:29.090 --> 31:33.140 +which is essentially a 60 byte base 64 encoded + +31:33.640 --> 31:36.860 +string, date type which is returned as a + +31:37.360 --> 31:40.620 +number. Double is returned as a number because These are the + +31:41.120 --> 31:44.580 +JavaScript types. Int is returned as a number + +31:45.700 --> 31:49.620 +and then there's location reference and + +31:50.120 --> 31:53.420 +then string and list. And how would you like, + +31:53.920 --> 31:57.300 +how do you do adjacent object like this? How would you even + +31:57.800 --> 31:59.860 +represent this in Swift? Because you don't know what type you're going to get. + +32:01.350 --> 32:04.510 +So like I said, this is a work in progress. + +32:05.010 --> 32:08.710 +Sorry. So what I do, I don't know how much you can see this. + +32:09.110 --> 32:13.910 +I'm going to actually move over to my documentation + +32:14.410 --> 32:18.590 +here at this point. So how + +32:19.090 --> 32:22.870 +are we doing on time? We good? Yeah, + +32:23.370 --> 32:25.910 +I think, I think we're doing good. Okay, cool. Any, + +32:26.560 --> 32:30.240 +do you want to ask questions? I don't + +32:30.740 --> 32:32.160 +have anything right now. + +32:33.760 --> 32:37.600 +Same nothing right now. But this seems applicable to things + +32:38.100 --> 32:40.480 +I'll be doing coming up. Okay, cool. + +32:43.200 --> 32:46.640 +So we have set up in the + +32:46.800 --> 32:50.240 +open. So we have an open API YAML file that you + +32:50.740 --> 32:55.370 +can pull up in Miskit, which is basically every like the + +32:55.870 --> 32:59.570 +documentation converted to YAML. And so what we do + +33:00.070 --> 33:03.410 +is you can set up in the YAML the + +33:03.910 --> 33:08.330 +field value requests and they have an enum type essentially for, + +33:12.090 --> 33:15.490 +for open API. So and then, + +33:15.990 --> 33:18.810 +so this has, you know, it could be one of either any of these types + +33:18.860 --> 33:22.730 +of. And then there's an enum in case you have + +33:23.230 --> 33:27.250 +a list. So if you have a list value + +33:27.330 --> 33:31.450 +type there is an extra property called type + +33:31.950 --> 33:36.050 +and then that will tell you what type the. The list is. And it's + +33:36.530 --> 33:40.210 +homo homomorphic. It's all the same list + +33:40.710 --> 33:42.210 +type. You can't have lists of different types. + +33:44.050 --> 33:49.230 +And then we have here again + +33:49.730 --> 33:52.750 +field value. Sometimes the type is available, + +33:52.910 --> 33:56.150 +sometimes it's not. But basically we have all + +33:56.650 --> 33:59.950 +the different value types available to us in a CK value. + +34:01.950 --> 34:05.670 +And then this is. Then the Open API + +34:06.170 --> 34:09.150 +generator essentially builds this for me which is. + +34:09.710 --> 34:13.630 +Has an enum and a struck for field field value request + +34:15.329 --> 34:18.449 +and then it does all the decoding for me. Thankfully I didn't have to + +34:18.949 --> 34:19.169 +do any of it. + +34:23.089 --> 34:26.569 +And then yeah, I just wanted to + +34:27.069 --> 34:30.209 +cover that piece where we show how we deal with these + +34:30.709 --> 34:34.289 +kind of like polymorphic types and how those work. + +34:35.329 --> 34:37.489 +The next thing I want to cover is error handling. + +34:39.249 --> 34:42.209 +So if you look at the documentation gives you. + +34:43.390 --> 34:48.350 +If you get an error we get something like this and + +34:48.850 --> 34:52.350 +then that will show you in the. In the table actually shows you what each + +34:52.830 --> 34:56.150 +error means. So again we do + +34:56.650 --> 35:00.110 +like an enum in YAML. It's basically a string + +35:00.610 --> 35:04.270 +and then we have everything else be a string. And then the open API + +35:04.770 --> 35:08.110 +generator will automatically generate this which + +35:08.610 --> 35:11.820 +gives us the server error code and the error response. + +35:12.380 --> 35:15.500 +It'll also do all this stuff here, which is really nice. + +35:17.980 --> 35:21.580 +And then we've then in our. We've abstracted a lot of + +35:22.080 --> 35:25.860 +this in miskit. So that way we also have now a + +35:26.360 --> 35:29.980 +cloud cloud error type which gives us a lot more + +35:30.060 --> 35:31.820 +info regarding that. + +35:33.900 --> 35:37.520 +So that's how we handle errors. And everything I do + +35:38.020 --> 35:42.200 +in the abs, the more abstract higher up stuff is done using + +35:42.360 --> 35:44.920 +type throws like I have type throws and everything. + +35:45.160 --> 35:47.240 +So that's how I handle that. + +35:48.600 --> 35:52.200 +Let me check one last piece I wanted to cover. + +35:54.920 --> 35:58.200 +The last piece I want to cover is really cool. And that is + +35:58.700 --> 36:01.920 +the authentication layer. So Open API provides + +36:02.420 --> 36:05.960 +what's called middleware and that allows you to, + +36:06.200 --> 36:09.120 +when you create a client or a server, you can plug that in and it + +36:09.620 --> 36:13.400 +will handle like let's say you need to make modifications with the request or response. + +36:13.640 --> 36:17.040 +When it comes in, you can intercept it and make whatever modifications + +36:17.540 --> 36:21.160 +you want to make. And in this case what + +36:21.660 --> 36:25.480 +we've done is I've created an authentication + +36:25.980 --> 36:29.800 +middleware which then sees if you have + +36:31.430 --> 36:35.310 +what's called a token manager and an authentic + +36:35.810 --> 36:39.350 +you have that and an authentication method. And the way it works + +36:39.510 --> 36:42.950 +is you pick what type of authentication you want to + +36:43.450 --> 36:46.789 +use. If you already have like a pre existing web token or you already have, + +36:47.289 --> 36:50.350 +or you, you know, have your key ID and your private key already, or you + +36:50.850 --> 36:54.470 +just have the API token. We've created basically a middleware that + +36:54.970 --> 36:59.120 +uses that. So this + +36:59.620 --> 37:03.160 +is how it creates the headers for server to server. So it + +37:03.660 --> 37:07.760 +does all this for us. And then what + +37:08.260 --> 37:11.760 +I added, which I think is really nice, is called the adaptive token manager. + +37:12.240 --> 37:17.360 +And the idea with that is like let's say you're + +37:17.860 --> 37:20.920 +using a client and you have the web authentication token + +37:21.420 --> 37:25.450 +now and then this allows you to upgrade with that web authentication token + +37:25.950 --> 37:27.730 +to the private database and have access to that. + +37:30.530 --> 37:33.970 +So and then all the, all the signing is done + +37:34.470 --> 37:38.090 +before you in miskit for the server to server because stuff that needs to be + +37:38.590 --> 37:42.170 +signed, etc. And it takes care of all that. All stuff + +37:42.670 --> 37:45.970 +that Claude was essentially able to decipher from + +37:46.610 --> 37:50.060 +the documentation. + +37:52.620 --> 37:54.300 +There's one more thing I wanted to show. + +37:56.380 --> 37:59.860 +If you want to hop in with a question while I pull something + +38:00.360 --> 38:00.940 +up, feel free. + +38:21.190 --> 38:24.390 +No questions. Cool. + +38:24.790 --> 38:28.630 +So I'm going to show one last thing and that is how + +38:28.710 --> 38:30.310 +do we actually deploy this? + +38:33.350 --> 38:36.950 +Is this too big, too small? Looks okay. + +38:37.590 --> 38:40.070 +That looks good. Yeah, it looks good. Okay, cool. + +38:43.850 --> 38:47.210 +So essentially what I've done is I'm using + +38:47.370 --> 38:50.410 +GitHub Actions. There's a way you can. + +38:53.130 --> 38:57.330 +This is all public by the way, so I will provide URLs + +38:57.830 --> 39:00.570 +in the Slack or something. Let's do this one. + +39:02.410 --> 39:07.220 +So this is a Swift package for + +39:07.720 --> 39:10.660 +Bushel. It's called Bushel Cloud. It pulls the stuff up from. + +39:11.220 --> 39:14.740 +Uses Miskit to go ahead and + +39:16.740 --> 39:20.340 +pull, get access to CloudKit and + +39:21.060 --> 39:24.860 +let me go back to the workflow. How familiar + +39:25.360 --> 39:26.580 +are you with GitHub workflows? + +39:29.860 --> 39:32.980 +Sadly not had the chance to work too deeply with them yet. + +39:33.690 --> 39:37.050 +Okay. Basically it's like for CI, but you can + +39:37.550 --> 39:41.570 +also set it up on a schedule. So I did that and + +39:42.070 --> 39:45.730 +then it runs the scheduled job and then I just + +39:46.230 --> 39:46.490 +execute. + +39:50.650 --> 39:54.650 +So then this was refactored over here into + +39:55.150 --> 39:58.490 +an action. There we go. + +39:59.540 --> 40:03.460 +And I have all sorts of stuff here for + +40:05.380 --> 40:10.300 +like this is generic essentially, but all + +40:10.800 --> 40:14.060 +these, the environment, etc. These are all passed + +40:14.560 --> 40:18.180 +from that workflow into here. These are basically either API keys or + +40:18.680 --> 40:22.100 +the information that I need for accessing Cloud, the public, + +40:24.020 --> 40:28.120 +public database. Right. And then I + +40:28.620 --> 40:32.040 +already pre built the binary. So we already + +40:32.540 --> 40:35.960 +have that. We're running this on Ubuntu because + +40:36.460 --> 40:40.280 +it's the default. Look at it. If there + +40:40.780 --> 40:44.400 +is no binary, it goes ahead and builds the binary for me. So that's + +40:44.900 --> 40:49.080 +what this is doing. And then we + +40:49.580 --> 40:53.290 +make sure the binary works. We make, we make it executable, we validate, + +40:53.790 --> 40:56.530 +make sure all the API secrets are there. + +40:57.650 --> 41:00.530 +We then go ahead and this validates the pim. + +41:00.690 --> 41:04.050 +But essentially this is the fun part. We go ahead, + +41:04.550 --> 41:07.730 +we have all our inputs for the private key, the key id, + +41:07.810 --> 41:11.050 +environment, container id. And then + +41:11.550 --> 41:14.450 +I use Virtual Buddy for signing verification. And. + +41:18.460 --> 41:21.940 +It then goes in and it runs the + +41:22.440 --> 41:25.660 +sync and then we'll go in. + +41:25.980 --> 41:29.900 +Basically it pulls from several websites information about + +41:30.400 --> 41:33.780 +macrosos, restore images and checks whether they're signed. And then + +41:34.280 --> 41:38.260 +it goes ahead and it adds those to the database. + +41:38.760 --> 41:42.100 +And then what this does is it exports the information in a run. + +41:42.600 --> 41:44.860 +Let's, let's take a look, see if I have one. I can show you. + +41:45.980 --> 41:47.420 +Oh, there's one scheduled. + +41:50.060 --> 41:54.060 +Yeah, here we go. So there's 57 new + +41:54.560 --> 41:58.300 +restore images created, 177 updated. + +41:58.780 --> 42:03.020 +234 total. No operations failed. + +42:03.100 --> 42:05.900 +I also store Xcode versions and Swift versions. + +42:06.780 --> 42:10.460 +Those get stored as well. Had to rebuild it, + +42:10.630 --> 42:11.830 +but here is the results. + +42:13.750 --> 42:17.750 +I'm not going to pull that up, but it's essentially updated + +42:18.250 --> 42:22.470 +my CloudKit database and + +42:22.550 --> 42:26.190 +that's all in the public database. And then maybe even by the time + +42:26.690 --> 42:30.230 +I present this, I'll have a working example in Bushel with that example working, + +42:30.630 --> 42:31.670 +which would be awesome. + +42:32.870 --> 42:36.630 +Celestra, same idea. So this looks like it was a RSS + +42:37.130 --> 42:42.830 +update. We get the workflow file and. + +42:43.330 --> 42:46.110 +Oh, sorry, I should point out, because you're probably wondering where is all these. + +42:46.610 --> 42:50.150 +The stuff all these secrets stored? Yes, they are stored in + +42:50.650 --> 42:53.910 +Actions secrets right here. So we have + +42:54.410 --> 42:58.190 +our private key ID API key from + +42:58.690 --> 43:02.750 +Virtual Buddy. So that's all stored there. Here is + +43:03.150 --> 43:06.350 +Celestra. It's for updating RSS feeds. + +43:07.050 --> 43:10.370 +So it just basically goes through. You can look at the Swift code it goes + +43:10.870 --> 43:15.930 +through, pulls RSS feeds and updates them into a CloudKit record + +43:16.410 --> 43:18.490 +or what do you call it? Yeah, record type. + +43:19.850 --> 43:22.210 +And I of course try to do it in such a way not to hammer + +43:22.710 --> 43:24.170 +people, but same idea, + +43:27.050 --> 43:30.610 +yeah, it goes ahead and it runs the + +43:31.110 --> 43:35.890 +binary it updates and then I also have like actual parameters + +43:36.390 --> 43:39.810 +that I take to to filter out, like which RSS feeds are high + +43:40.310 --> 43:44.330 +priority and which ones aren't based on the audience and etc. So yeah, + +43:44.890 --> 43:48.410 +so that's deployment. That's how you can get that working. + +43:48.810 --> 43:53.130 +There's weird stuff with cloud with GitHub that + +43:53.690 --> 43:57.210 +I've noticed. If you haven't updated it in a while, it doesn't run these + +43:57.710 --> 43:59.570 +cron jobs. So I need to figure out a how to get around it or + +44:00.070 --> 44:03.550 +find another service to do it. This is all free + +44:03.630 --> 44:07.310 +because it's public and it + +44:07.810 --> 44:09.870 +is running on Ubuntu. So that's really great. + +44:12.350 --> 44:16.310 +And the storage on CloudKit is dirt cheap, which is even more + +44:16.810 --> 44:16.830 +awesome. + +44:20.030 --> 44:23.990 +Sorry, let's see what else. I just + +44:24.490 --> 44:27.150 +want to make sure I covered all my slides. The last thing I'm going to + +44:27.650 --> 44:31.030 +talk about is just what are my plans? Excuse me. + +44:31.510 --> 44:34.550 +So I don't know if you check. Follow me. But I just released. + +44:41.910 --> 44:45.390 +I just released Alpha 5 that has lookup + +44:45.890 --> 44:49.270 +zones, fetch, record changes and upload assets. Upload the assets + +44:49.770 --> 44:52.470 +is pretty awesome. When I saw that work because I was like, cool, I can + +44:52.970 --> 44:56.230 +actually upload a binary to CloudKit, which is awesome. + +44:57.310 --> 45:00.550 +We got query filters to work for in and not in, so you could do + +45:01.050 --> 45:04.230 +that I have plans to continue working on this because I think + +45:04.730 --> 45:06.990 +there's a big future for something like this for a lot of people. + +45:09.150 --> 45:12.270 +Yes, you can technically use this in Android or Windows + +45:12.670 --> 45:16.230 +because the Swift thing does compile in Android and Windows. + +45:16.730 --> 45:19.790 +You can see I already added support for that. This is the support I recently + +45:19.870 --> 45:23.200 +had. And then we're. I'm just kind of like + +45:23.700 --> 45:27.000 +going through each of these because as great as AI is, it's not perfect. + +45:27.080 --> 45:30.760 +So we're just kind of going through these piece by piece + +45:30.840 --> 45:35.720 +with each version and hammering these away and + +45:36.220 --> 45:40.160 +then this is actually done. I don't even know why that's there. But yeah, + +45:40.660 --> 45:44.760 +I think system field integration might already be there and there's a few other things. + +45:45.960 --> 45:49.200 +Eventually I'd like to add support. So there, there's a + +45:49.700 --> 45:53.200 +whole API for CloudKit schema management that I could. + +45:53.700 --> 45:56.120 +That would be awesome if I could figure out how to do that. If I + +45:56.620 --> 45:59.400 +could figure out how to do key path query filtering, that would be fantastic. + +46:01.720 --> 46:05.280 +And yeah, but there's a. I mean the basics is there as + +46:05.780 --> 46:09.080 +far as if you want to do anything with a record, it's pretty much there. + +46:09.720 --> 46:13.160 +One thing with Celestra is I'd love to be able to do like test out + +46:13.660 --> 46:17.840 +subscriptions and see how that works. So yeah, + +46:18.340 --> 46:20.040 +that's really the bulk of my presentation today. + +46:21.800 --> 46:24.880 +Now is. Now it's time to ask me a ton of questions and make me + +46:25.380 --> 46:28.840 +feel dumb. Go for it. No, + +46:29.880 --> 46:33.400 +there's a lot there to. To absorb. But I, I like + +46:33.900 --> 46:36.680 +the concept and I know you've been working on this for a while and I + +46:37.180 --> 46:41.630 +always thought it was a pretty cool, pretty cool idea and implementation + +46:42.130 --> 46:43.470 +of this. Questions? + +46:48.990 --> 46:50.030 +So with something like. + +46:54.110 --> 46:57.510 +Accessing CloudKit through the web, is this + +46:58.010 --> 47:01.630 +setup more ideal for having your server + +47:01.870 --> 47:05.550 +do the authentication to CloudKit with Miskit + +47:05.970 --> 47:09.890 +or is miskit something that you could put into even like a client + +47:10.130 --> 47:15.090 +side, you know, like non + +47:15.810 --> 47:19.410 +Swift application or I guess not non Swift but like non like + +47:19.910 --> 47:22.049 +app application. I'm thinking in the context of like a. + +47:25.730 --> 47:30.290 +I guess if I wanted to create a something + +47:30.790 --> 47:33.410 +accessing CloudKit that is not your typical Mac or iOS app. + +47:34.880 --> 47:38.480 +Can you be more specific? I'm looking + +47:38.720 --> 47:42.040 +into one. One approach would be browser + +47:42.540 --> 47:46.000 +extensions. So for + +47:46.500 --> 47:48.240 +like a non Safari browser. Yes. + +47:50.400 --> 47:54.120 +Yeah, this would be great. So basically the way you'd want + +47:54.620 --> 47:58.240 +that to work, like the sticky part to me would be getting the web authentication + +47:58.740 --> 48:01.090 +token. Other than that, like have at it. + +48:04.610 --> 48:08.770 +So I'm gonna, I'm gonna be devil's advocate. Why not just use the CloudKit + +48:08.850 --> 48:11.490 +JavaScript library. If it's an extension, + +48:12.450 --> 48:16.129 +my brain jumps to Swift first. Right. + +48:16.629 --> 48:18.930 +But it's the reason I'm asking that is like it's a, + +48:19.410 --> 48:22.050 +it's already a web extension. I would assume that is true. + +48:22.690 --> 48:26.010 +That it's 90 web based or JavaScript + +48:26.510 --> 48:29.600 +based. So that's where I'm just like, well, you may as well. Like, + +48:29.840 --> 48:32.800 +I would love. I don't want to. Like, I love tooting my own horn. + +48:33.300 --> 48:37.120 +Right. But like, like why not just. Unless you're. + +48:40.720 --> 48:43.840 +Unless you're like building a executable, + +48:44.160 --> 48:45.920 +I guess, or an app. Ish. + +48:47.760 --> 48:50.960 +And I guess another application for this would be + +48:51.680 --> 48:55.920 +doing CloudKit stuff server side and then providing my own API + +48:56.420 --> 48:59.860 +layer over it. Yep, yep. So that's. + +49:00.360 --> 49:03.740 +Yeah. Are we talking private database or public database? Private. + +49:05.580 --> 49:09.380 +So in that case, basically like you'd have to go the + +49:09.880 --> 49:12.979 +Hard Twitch route and you would + +49:13.479 --> 49:16.820 +have to provide a way to get their web + +49:17.320 --> 49:19.900 +authentication token, essentially, if that makes sense. + +49:20.540 --> 49:23.260 +And then store it in Postgres or whatever the hell you want to do. + +49:23.760 --> 49:26.880 +Like that's, that's the way I did it with Hard Twitch. But once you have + +49:27.380 --> 49:31.200 +that, you can do anything you want on the server with their private database, + +49:31.700 --> 49:34.480 +if that makes sense. It does. Yep. + +49:34.560 --> 49:37.920 +Yep. A couple of things I wanted to bring + +49:38.420 --> 49:39.520 +up, so let's take a look. + +49:44.000 --> 49:48.400 +So part of my other presentation + +49:48.640 --> 49:51.880 +is working, talking about cross + +49:52.380 --> 49:54.440 +platform automation type stuff. + +49:55.560 --> 49:58.840 +And the one issue I've run into is. + +49:58.920 --> 50:01.560 +So it basically builds on everything. Right now. + +50:07.560 --> 50:11.320 +I'm going to share something. Hey guys, I got + +50:11.820 --> 50:15.240 +to drop. But it was good presentation, Leo. Thank you. Yeah, + +50:15.740 --> 50:17.760 +yeah. If I have more questions, if you have any feedback, just hit me up + +50:18.260 --> 50:21.710 +on Slack. Sounds good. Cool, thank you. Thank you so much for + +50:22.210 --> 50:25.350 +helping me set this up. Yeah, talk to you later. Thank you. + +50:25.850 --> 50:29.190 +Bye bye. Yeah, + +50:29.690 --> 50:31.790 +so if you had something else to show, I'm happy to look for. I'm here + +50:32.290 --> 50:34.390 +for a few more minutes as well. Yeah, yeah, yeah. + +50:38.790 --> 50:43.110 +So I have the workflow working here and it does Ubuntu, + +50:44.080 --> 50:48.000 +it does Windows, it does Android. So all that stuff is available to you. + +50:48.640 --> 50:52.040 +I would never recommend using Miskit on an Apple platform + +50:52.540 --> 50:56.080 +for obvious reasons, like what's the point? True. + +50:56.580 --> 50:59.920 +Unless there's something special that I provide that CloudKit doesn't like, I don't + +51:00.420 --> 51:03.840 +get it. Right. But we have an issue. + +51:03.920 --> 51:07.640 +So I just started dabbling. I haven't really done anything + +51:08.140 --> 51:11.730 +with wasm, but I did definitely try. Like I added support for + +51:12.230 --> 51:14.890 +WASM in my, in my Swift build action. + +51:17.210 --> 51:21.530 +The thing about WASA is it does not provide. It doesn't have a transport available. + +51:22.570 --> 51:24.410 +So we talked about transports, + +51:26.010 --> 51:30.090 +I think. Did you hear about that part about the Open API generator and transports? + +51:31.370 --> 51:33.690 +I think I was coming in at that point. + +51:34.410 --> 51:38.310 +Okay. When you create a client, so underneath + +51:38.810 --> 51:42.630 +the client you + +51:43.130 --> 51:46.990 +have what's called a client transport. This is so underneath this + +51:47.490 --> 51:50.829 +client, this is an abstraction layer above. So this is not + +51:51.329 --> 51:53.390 +the right one. Where's the public one? + +52:00.680 --> 52:05.440 +But anyway, there is here + +52:05.940 --> 52:06.920 +CloudKit service maybe. + +52:09.560 --> 52:13.640 +Yeah, here we go. So the CloudKit service has + +52:14.140 --> 52:17.960 +a client and part of the client is being able + +52:19.960 --> 52:23.560 +to say what transport you use in Open API. + +52:24.760 --> 52:29.330 +And there's + +52:29.830 --> 52:33.730 +two transports available right now. One is, + +52:36.850 --> 52:40.930 +one is your regular URL session for clients, which. That makes sense. + +52:41.430 --> 52:45.410 +Right. And then there's the Async HTTP client which is typically used + +52:45.570 --> 52:47.970 +like Swift NEO based for servers. + +52:49.330 --> 52:53.170 +The thing is that neither of those are available in wasp. + +52:54.290 --> 52:57.810 +Do you know what WASM is? I have no experience with it, but yes. + +52:58.850 --> 53:01.490 +Okay. It's. It's the web browser. Right. + +53:01.890 --> 53:04.850 +So. So you really can't use Miskit in. + +53:06.450 --> 53:09.650 +In the. In WASM yet because there is no transport. Now having + +53:10.150 --> 53:12.450 +said that, why on earth would you use. + +53:13.090 --> 53:16.970 +Awesome. Why would you use Miskit in the browser? Why not just use CloudKit + +53:17.470 --> 53:20.700 +js? So that's essentially, + +53:21.580 --> 53:22.060 +you know, + +53:29.260 --> 53:30.940 +What other questions do you have? + +53:35.660 --> 53:41.340 +My brain is mushy right now, so because + +53:41.840 --> 53:45.850 +of my presentation or because other things, I got two hours of sleep. + +53:46.650 --> 53:50.170 +Oh, I'm so sorry. So I'm + +53:50.670 --> 53:51.450 +following as best as I can. + +53:54.330 --> 53:58.010 +Snuggling. Yeah, the intro + +53:58.090 --> 54:01.570 +was basically how I originally built it for + +54:02.070 --> 54:06.210 +hard Twitch in 2020 for a private database login for + +54:06.710 --> 54:09.210 +the Apple Watch because I don't want to have a login screen. And so basically + +54:09.710 --> 54:12.490 +there's a way in the web browser to link your Apple Watch to your account + +54:12.990 --> 54:16.280 +and then from there you don't need to authenticate anymore. Nice. I built + +54:16.780 --> 54:20.560 +that all from hand and then in 23 they came out + +54:21.060 --> 54:24.160 +with the Open API generator which was like, oh wait, + +54:24.660 --> 54:29.040 +what if I can create an open API file out of Apple's + +54:29.280 --> 54:30.800 +10 year old documentation? + +54:33.120 --> 54:36.560 +That'd be a lot of work, but I could do it. And I don't know + +54:37.060 --> 54:40.720 +if you heard, but there was this thing that came out a couple + +54:41.220 --> 54:45.340 +years ago called AI and it's + +54:45.840 --> 54:49.140 +really good at creating documentation for your code, but it's also really good at creating + +54:49.640 --> 54:53.940 +code for your documentation. And so I was like, oh yeah, + +54:54.440 --> 54:57.739 +this is great. Like I can just, I can just Feed + +54:58.239 --> 55:01.620 +it the documentation and go from + +55:02.120 --> 55:05.140 +there. And, like, basically, I've been going step by step through. + +55:05.940 --> 55:09.300 +Like I said, if you looked at the miskit repo, + +55:09.800 --> 55:14.620 +like, I'm going through step by step and adding new APIs based + +55:15.120 --> 55:18.180 +on what's available in the documentation, piece by piece. And I would say at this + +55:18.680 --> 55:21.940 +point, it's like most of the really, like 80% of that + +55:22.440 --> 55:26.340 +people use is there. There's like, stuff like subscriptions and zones that I'm + +55:26.840 --> 55:30.260 +still trying to figure out, but it's. It's pretty close to done + +55:30.760 --> 55:31.900 +at this point. Mm. + +55:35.110 --> 55:38.590 +If you use it. Yeah, it's one of those. Because I. Go ahead. + +55:39.090 --> 55:41.070 +Yeah. I was gonna say it's one of those projects that makes me want to + +55:41.570 --> 55:45.110 +set up a. Like a vapor server or something just to do some Swift on + +55:45.610 --> 55:49.390 +the server. Yeah. Or just like, I wonder + +55:49.890 --> 55:52.990 +if there's like, something you do on a pie, like just hook it up to + +55:53.490 --> 55:56.030 +a CloudKit database. Like, there's a lot you could do here because all you need + +55:56.530 --> 56:00.430 +is decent os. I don't know anything about sharing. + +56:00.930 --> 56:03.390 +I haven't done anything with sharing yet, so I still have to do that and + +56:03.890 --> 56:05.740 +a few other things, but. No, yeah, + +56:07.740 --> 56:10.460 +it's an interesting idea. Thank you. + +56:11.420 --> 56:15.340 +Yeah. Well, thank you for joining, Josh. Yeah. Thanks for hosting this and + +56:15.900 --> 56:19.260 +sharing this info. It's nice. Yeah. If you + +56:19.760 --> 56:22.060 +ever run into anything, let me know. Will do. + +56:22.940 --> 56:25.660 +All right, talk to you later. All right, sounds good. See you. + +56:26.220 --> 56:26.700 +Bye. diff --git a/docs/why-mistkit.md b/docs/why-mistkit.md new file mode 100644 index 00000000..02971ab8 --- /dev/null +++ b/docs/why-mistkit.md @@ -0,0 +1,60 @@ +# Why do I need MistKit? + +Apple's CloudKit framework only runs on Apple platforms. MistKit wraps the CloudKit Web Services REST API so that server-side Swift, Linux services, and command-line tools can participate in the same CloudKit ecosystem as your iOS and macOS apps. + +### Public Database as a Managed Content Catalog + +The most common pattern: a server-side job manages a CloudKit public database that Apple devices query. + +**macOS VM restore image catalog (Bushel)** +A Mac management tool stores available macOS VM restore images as CloudKit records. A server-side service adds new images and updates metadata as Apple releases them. Client apps query the public database to discover what's available to download — no custom API needed. + +**RSS/feed aggregation (Celestra)** +A server-side service fetches RSS or Atom feeds on a schedule, parses the entries, and writes them as CloudKit records. Apple devices query the public database to get aggregated content, with CloudKit handling sync and delivery. + +**Software version catalogs** +Track available Xcode versions, simulator runtimes, or SDK releases. A server-side job writes structured records into CloudKit; developer tools on-device query the public database to show what's available. + +**App asset distribution** +A creative app (fonts, templates, themes, presets) stores downloadable asset packs as CloudKit records. A server-side tool manages the catalog — adding packs, updating metadata, deprecating old ones — without requiring an app update. + +**Feature flags / remote config** +Store feature flag configurations as CloudKit records. A server-side admin tool writes flag values; Apple devices read them from the public database without needing a dedicated service. + +**MDM configuration catalogs** +Configuration profiles, scripts, or policy templates stored in CloudKit public database. A web-based admin console writes and updates them server-side; managed Macs fetch and apply them. + +### Private Database: Acting on Behalf of a User + +When a user authenticates once and the server stores their web auth token, the server can read and write their CloudKit private database asynchronously — without the user being present. This is the same model as storing OAuth tokens for Gmail or Dropbox access. + +**Wearable / peripheral device linking (Heartwitch)** +A user authenticates in the iOS app, and the server stores their web auth token. The server then connects their Apple Watch data to an external service (e.g., a live streaming platform) continuously — reading records the Watch writes to CloudKit and bridging them to the third-party API without requiring active user interaction each time. + +**Wearable data pipelines** +An app writes activity or sensor data from an Apple Watch or other device to the user's CloudKit private database. A server reads those records and pushes them to a fitness platform, research database, or coaching service. + +**Always-on device presence** +A user's devices write location or status records to their private database. A server monitors those records and triggers actions in another system — fleet tracking, family safety apps, or delivery coordination. + +**Two-way sync with external services** +A server reads a user's CloudKit private records and syncs them to genuinely external platforms (Todoist, Notion, Google Calendar, Obsidian) — and writes changes back — acting as a persistent background sync bridge between CloudKit and the rest of the user's toolchain. + +**Server-side processing** +A user uploads a photo or document to their private database. A server fetches it, runs processing (OCR, image resizing, transcoding, AI tagging), then writes the results back as new records. + +### Web App ↔ Apple Device Bridge + +**Web portal for a CloudKit-backed app** +A user signs into a web app with their Apple ID. The server exchanges that web auth token for CloudKit access and reads/writes the user's private database — giving them a browser-based view of their iOS app data without requiring the app to be open. + +**Webhook → CloudKit writer** +An external service (Stripe, GitHub, a form submission) triggers a webhook. A server-side Swift handler writes the result directly into a CloudKit record, which instantly syncs to the user's Apple devices. + +### Data Aggregation + +**Anonymized telemetry** +Devices write anonymized usage events to CloudKit. A server-side job reads those records via `/records/changes`, aggregates them, and stores results elsewhere for analysis — without building a custom ingestion API. + +**Crowdsourced data** +Apps contribute data points (WiFi maps, accessibility ratings, transit times) to a CloudKit public database. A server aggregates, deduplicates, and enriches the records, then writes cleaned data back — acting as a background data steward. diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..9be8b4f9 --- /dev/null +++ b/mise.toml @@ -0,0 +1,8 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4" +"spm:apple/swift-openapi-generator" = "1.10.3" diff --git a/openapi-generator-config.yaml b/openapi-generator-config.yaml index 8942f958..128f9dfc 100644 --- a/openapi-generator-config.yaml +++ b/openapi-generator-config.yaml @@ -1,7 +1,7 @@ generate: - types - client -accessModifier: internal +accessModifier: public additionalFileComments: - periphery:ignore:all - swift-format-ignore-file diff --git a/openapi.yaml b/openapi.yaml index 23efc408..b92a6d28 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -85,27 +85,27 @@ paths: schema: $ref: '#/components/schemas/QueryResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/responses/Failure' '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/responses/Failure' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Failure' '412': - $ref: '#/components/responses/PreconditionFailed' + $ref: '#/components/responses/Failure' '413': - $ref: '#/components/responses/RequestEntityTooLarge' + $ref: '#/components/responses/Failure' '429': - $ref: '#/components/responses/TooManyRequests' + $ref: '#/components/responses/Failure' '421': - $ref: '#/components/responses/UnprocessableEntity' + $ref: '#/components/responses/Failure' '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/Failure' '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/records/modify: post: @@ -141,27 +141,27 @@ paths: schema: $ref: '#/components/schemas/ModifyResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/responses/Failure' '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/responses/Failure' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Failure' '412': - $ref: '#/components/responses/PreconditionFailed' + $ref: '#/components/responses/Failure' '413': - $ref: '#/components/responses/RequestEntityTooLarge' + $ref: '#/components/responses/Failure' '429': - $ref: '#/components/responses/TooManyRequests' + $ref: '#/components/responses/Failure' '421': - $ref: '#/components/responses/UnprocessableEntity' + $ref: '#/components/responses/Failure' '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/Failure' '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/records/lookup: post: @@ -201,27 +201,27 @@ paths: schema: $ref: '#/components/schemas/LookupResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/responses/Failure' '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/responses/Failure' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Failure' '412': - $ref: '#/components/responses/PreconditionFailed' + $ref: '#/components/responses/Failure' '413': - $ref: '#/components/responses/RequestEntityTooLarge' + $ref: '#/components/responses/Failure' '429': - $ref: '#/components/responses/TooManyRequests' + $ref: '#/components/responses/Failure' '421': - $ref: '#/components/responses/UnprocessableEntity' + $ref: '#/components/responses/Failure' '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/Failure' '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/records/changes: post: @@ -257,27 +257,27 @@ paths: schema: $ref: '#/components/schemas/ChangesResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/responses/Failure' '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/responses/Failure' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Failure' '412': - $ref: '#/components/responses/PreconditionFailed' + $ref: '#/components/responses/Failure' '413': - $ref: '#/components/responses/RequestEntityTooLarge' + $ref: '#/components/responses/Failure' '429': - $ref: '#/components/responses/TooManyRequests' + $ref: '#/components/responses/Failure' '421': - $ref: '#/components/responses/UnprocessableEntity' + $ref: '#/components/responses/Failure' '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/Failure' '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/zones/list: get: @@ -299,27 +299,27 @@ paths: schema: $ref: '#/components/schemas/ZonesListResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/responses/Failure' '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/responses/Failure' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Failure' '412': - $ref: '#/components/responses/PreconditionFailed' + $ref: '#/components/responses/Failure' '413': - $ref: '#/components/responses/RequestEntityTooLarge' + $ref: '#/components/responses/Failure' '429': - $ref: '#/components/responses/TooManyRequests' + $ref: '#/components/responses/Failure' '421': - $ref: '#/components/responses/UnprocessableEntity' + $ref: '#/components/responses/Failure' '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/Failure' '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/zones/lookup: post: @@ -352,9 +352,9 @@ paths: schema: $ref: '#/components/schemas/ZonesLookupResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/zones/modify: post: @@ -387,9 +387,9 @@ paths: schema: $ref: '#/components/schemas/ZonesModifyResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/zones/changes: post: @@ -421,9 +421,9 @@ paths: schema: $ref: '#/components/schemas/ZoneChangesResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/subscriptions/list: get: @@ -445,9 +445,9 @@ paths: schema: $ref: '#/components/schemas/SubscriptionsListResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/subscriptions/lookup: post: @@ -483,9 +483,9 @@ paths: schema: $ref: '#/components/schemas/SubscriptionsLookupResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/subscriptions/modify: post: @@ -518,15 +518,19 @@ paths: schema: $ref: '#/components/schemas/SubscriptionsModifyResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' - /database/{version}/{container}/{environment}/{database}/users/current: + /database/{version}/{container}/{environment}/{database}/users/caller: get: - summary: Get Current User - description: Fetch the current authenticated user's information - operationId: getCurrentUser + summary: Get the Caller (Current User) + description: | + Fetch the authenticated caller's user information. This replaces the deprecated + `users/current` endpoint. Requires public database with a web-auth token + (user-context auth); server-to-server credentials and the private database + will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + operationId: getCaller tags: - Users parameters: @@ -542,27 +546,27 @@ paths: schema: $ref: '#/components/schemas/UserResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/responses/Failure' '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/responses/Failure' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/Failure' '412': - $ref: '#/components/responses/PreconditionFailed' + $ref: '#/components/responses/Failure' '413': - $ref: '#/components/responses/RequestEntityTooLarge' + $ref: '#/components/responses/Failure' '429': - $ref: '#/components/responses/TooManyRequests' + $ref: '#/components/responses/Failure' '421': - $ref: '#/components/responses/UnprocessableEntity' + $ref: '#/components/responses/Failure' '500': - $ref: '#/components/responses/InternalServerError' + $ref: '#/components/responses/Failure' '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/users/discover: post: @@ -602,9 +606,117 @@ paths: schema: $ref: '#/components/schemas/DiscoverResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' + # GET /users/discover — see #28 + get: + summary: Discover All User Identities + description: | + Fetch every user identity in the caller's CloudKit address book. + Requires public-database routing with web-auth credentials (user-context + auth); only users who have run the app and granted discoverability are + returned. + operationId: discoverAllUserIdentities + tags: + - Users + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + responses: + '200': + description: All discoverable user identities returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' + + /database/{version}/{container}/{environment}/{database}/users/lookup/email: + post: + summary: Lookup Users by Email + description: | + Look up user identities by email address. Requires public-database + routing with web-auth credentials (user-context auth). Each requested + email returns at most one identity in the `users` array. + operationId: lookupUsersByEmail + tags: + - Users + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + type: object + properties: + emailAddress: + type: string + responses: + '200': + description: User identities returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' + + /database/{version}/{container}/{environment}/{database}/users/lookup/id: + post: + summary: Lookup Users by Record Name + description: | + Look up user identities by record name (CloudKit user record ID). + Requires public-database routing with web-auth credentials. + operationId: lookupUsersByRecordName + tags: + - Users + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + type: object + properties: + userRecordName: + type: string + responses: + '200': + description: User identities returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/users/lookup/contacts: post: @@ -638,9 +750,9 @@ paths: schema: $ref: '#/components/schemas/ContactsResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/assets/upload: post: @@ -697,9 +809,9 @@ paths: schema: $ref: '#/components/schemas/AssetUploadResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/tokens/create: post: @@ -731,9 +843,9 @@ paths: schema: $ref: '#/components/schemas/TokenResponse' '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' /database/{version}/{container}/{environment}/{database}/tokens/register: post: @@ -761,9 +873,9 @@ paths: '200': description: Token registered successfully '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/responses/Failure' '401': - $ref: '#/components/responses/Unauthorized' + $ref: '#/components/responses/Failure' components: securitySchemes: @@ -1363,68 +1475,23 @@ components: type: string responses: - BadRequest: - description: Bad request (400) - BAD_REQUEST, ATOMIC_ERROR - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - Unauthorized: - description: Unauthorized (401) - AUTHENTICATION_FAILED - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - Forbidden: - description: Forbidden (403) - ACCESS_DENIED - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - NotFound: - description: Not found (404) - NOT_FOUND, ZONE_NOT_FOUND - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - Conflict: - description: Conflict (409) - CONFLICT, EXISTS - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - PreconditionFailed: - description: Precondition failed (412) - VALIDATING_REFERENCE_ERROR - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - RequestEntityTooLarge: - description: Request entity too large (413) - QUOTA_EXCEEDED - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - TooManyRequests: - description: Too many requests (429) - THROTTLED - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - UnprocessableEntity: - description: Unprocessable entity (421) - AUTHENTICATION_REQUIRED - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - InternalServerError: - description: Internal server error (500) - INTERNAL_ERROR - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - ServiceUnavailable: - description: Service unavailable (503) - TRY_AGAIN_LATER + Failure: + description: | + Error response shared by all endpoints. The body schema is the same for + every 4xx/5xx status code; the HTTP status code itself disambiguates + which CloudKit failure occurred. See Apple's CloudKit Web Services + Error Codes documentation for the full code → status mapping: + - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + - 401 Unauthorized (AUTHENTICATION_FAILED) + - 403 Forbidden (ACCESS_DENIED) + - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + - 409 Conflict (CONFLICT, EXISTS) + - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + - 429 TooManyRequests (THROTTLED) + - 500 InternalServerError (INTERNAL_ERROR) + - 503 ServiceUnavailable (TRY_AGAIN_LATER) content: application/json: schema: