hotdata 0.1.2

Powerful data platform API for datasets, queries, and analytics.
Documentation
name: Regenerate Client

on:
  workflow_dispatch:
    inputs:
      title:
        description: PR title, auto-generated from the spec diff in www.
        required: false
        type: string
      summary:
        description: PR body summarizing the spec changes.
        required: false
        type: string

jobs:
  regenerate:
    runs-on: ubuntu-latest
    steps:
      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          client-id: Iv23liKBX2RYMoZIYuKa
          private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }}
          owner: hotdata-dev

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          token: ${{ steps.app-token.outputs.token }}

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          toolchain: stable
          components: rustfmt

      - name: Fetch merged OpenAPI spec
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          curl -sS -f -L \
            -H "Accept: application/vnd.github.v3.raw" \
            -H "Authorization: Bearer $GH_TOKEN" \
            https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/openapi.yaml \
            -o openapi.yaml

      # OpenAPI 3.1 lets a `$ref` carry sibling keywords (e.g. a per-branch
      # `description` on a `oneOf` member). That's valid, and the Python/TS
      # generators consume it fine, but the Rust backend NPEs on it
      # (AbstractRustCodegen.toModelName — reproduced through generator 7.22.0,
      # no upstream fix yet). Flatten `$ref`-with-siblings to pure refs in our
      # throwaway generation input only; the canonical www spec and the other
      # SDKs are untouched. See scripts/normalize-openapi.py.
      - name: Normalize spec for the Rust generator
        run: |
          python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml
          python3 scripts/normalize-openapi.py openapi.yaml

      # Targeted clean: delete ONLY generator-owned subtrees. The hand-written
      # ergonomic layer (src/lib.rs, src/auth.rs, src/arrow.rs, src/client.rs)
      # is preserved here and additionally protected by .openapi-generator-ignore
      # so the generator re-emits src/apis and src/models in place without
      # clobbering the regen-immune modules or Cargo.toml.
      - name: Clean generated source
        run: rm -rf src/apis src/models docs

      # Cargo.toml is ignore-protected, so the generator no longer rewrites the
      # version. We own the patch bump here via cargo-edit and feed the result
      # to the generator as packageVersion + user-agent for consistency.
      - name: Bump patch version
        id: pkg
        run: |
          cargo install cargo-edit --locked >/dev/null 2>&1 || cargo install cargo-edit
          cargo set-version --bump patch
          version=$(cargo metadata --no-deps --format-version 1 \
            | jq -r '.packages[] | select(.name=="hotdata") | .version')
          echo "version=$version" >> "$GITHUB_OUTPUT"

      - name: Generate client
        env:
          PACKAGE_VERSION: ${{ steps.pkg.outputs.version }}
        run: |
          # useChrono=false: 7.22.0 flipped the Rust generator's useChrono
          # default to true, which emits chrono::DateTime for date-time fields.
          # The crate has no chrono dependency and the ergonomic layer + tests
          # treat dates as String (as 7.20.0 generated them), so keep String.
          npx @openapitools/openapi-generator-cli generate \
            -i openapi.yaml \
            -g rust \
            -o . \
            -t .openapi-generator-templates \
            --additional-properties=packageName=hotdata,packageVersion=$PACKAGE_VERSION,library=reqwest,supportAsync=true,useChrono=false \
            --http-user-agent "hotdata-rust/${PACKAGE_VERSION}" \
            --skip-validate-spec

      # The 7.22.0 generator emits compact, non-rustfmt output, which buries real
      # spec changes under formatting churn in every regen diff. Format only the
      # generated subtrees so the diff shows semantic changes; the hand-written
      # ergonomic layer is left to its own formatting (and is ignore-protected
      # from the generator anyway).
      - name: Format generated code
        run: |
          # Derive the edition from Cargo.toml so it can't drift from the crate
          # if the edition is ever bumped (same source as the version bump above).
          edition=$(cargo metadata --no-deps --format-version 1 \
            | jq -r '.packages[] | select(.name=="hotdata") | .edition')
          find src/apis src/models -name '*.rs' -print0 \
            | xargs -0 rustfmt --edition "$edition"

      - name: Clean up fetched spec
        run: rm -f openapi.yaml

      # Regen-safety guard (the Rust analog of sdk-python's AST check). Fails the
      # build loudly if the generator clobbered the hand-written ergonomic layer
      # or if the JWT/bearer template hooks were dropped or renamed by a future
      # generator version. Runs BEFORE cargo check so the failure is precise.
      - name: Verify ergonomic layer survived regen
        run: |
          set -euo pipefail
          fail=0

          # 1. Hand-written, regen-immune modules must exist.
          for f in src/lib.rs src/auth.rs src/arrow.rs src/client.rs src/resources.rs src/field.rs src/status.rs src/http_log.rs; do
            if [ ! -f "$f" ]; then
              echo "::error::$f is missing (regen clobbered the ergonomic layer)"
              fail=1
            fi
          done

          # 2. lib.rs must still wire the hand-written modules.
          for decl in 'pub mod auth;' 'pub mod client;' 'pub mod arrow;' 'pub mod resources;' 'pub mod field;' 'pub mod status;' 'pub mod http_log;'; do
            if ! grep -q "$decl" src/lib.rs; then
              echo "::error::src/lib.rs no longer declares '$decl'"
              fail=1
            fi
          done

          # 3. lib.rs must still re-export the ergonomic surface.
          if ! grep -Eq 'pub use (crate::)?auth::' src/lib.rs; then
            echo "::error::src/lib.rs no longer re-exports the auth surface"
            fail=1
          fi
          if ! grep -Eq 'pub use (crate::)?client::' src/lib.rs; then
            echo "::error::src/lib.rs no longer re-exports the client surface"
            fail=1
          fi

          # 4. The JWT/bearer template hooks must have survived the generator.
          #    configuration.mustache emits resolve_bearer_token on Configuration;
          #    api.mustache calls it at every bearer-auth site.
          if ! grep -q 'resolve_bearer_token' src/apis/configuration.rs; then
            echo "::error::resolve_bearer_token missing from generated configuration.rs (configuration.mustache drift)"
            fail=1
          fi
          if ! grep -rq 'resolve_bearer_token().await' src/apis/; then
            echo "::error::resolve_bearer_token().await missing from generated apis (api.mustache bearer hook drift)"
            fail=1
          fi

          # 5. The request/response debug-logging hooks must have survived the
          #    generator. api.mustache emits crate::http_log::log_request +
          #    log_response_status/_body at every op (issue #135).
          if ! grep -rq 'crate::http_log::log_request' src/apis/; then
            echo "::error::http_log::log_request missing from generated apis (api.mustache debug-logging hook drift)"
            fail=1
          fi
          if ! grep -rq 'crate::http_log::log_response_status' src/apis/; then
            echo "::error::http_log::log_response_status missing from generated apis (api.mustache debug-logging hook drift)"
            fail=1
          fi

          if [ "$fail" -ne 0 ]; then
            echo "::error::Regen-safety check failed: the ergonomic layer or auth/logging hooks did not survive regeneration."
            exit 1
          fi
          echo "Ergonomic layer survived regeneration: hand-written modules, lib.rs wiring, and JWT/bearer + debug-logging hooks all intact."

      # cargo check is the template-drift tripwire. --all-features compiles the
      # optional arrow surface too. The --no-run test build catches breakage in
      # the integration tests that target the generated apis.
      - name: Verify generated client compiles
        run: |
          cargo check --all-features
          cargo test --all-features --no-run

      # Soft parity warning: surface scenarios that still lack a tests/<name>.rs.
      # Non-fatal during the per-scenario rollout (rust is exempt until its tests
      # land); the enforcing gate lives in integration-tests.yml.
      - name: Check integration test scenario parity
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          curl -sS -f -L \
            -H "Accept: application/vnd.github.v3.raw" \
            -H "Authorization: Bearer $GH_TOKEN" \
            https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \
            -o test-scenarios.yaml
          python3 - <<'PY'
          import sys, pathlib, re
          text = pathlib.Path("test-scenarios.yaml").read_text()
          # Minimal stdlib parse: walk "- name:" blocks, capture optional_for.
          missing = []
          total = 0
          name = None
          optional = []
          def flush(name, optional):
              if name is None:
                  return
              if "rust" in optional:
                  return
              expected = pathlib.Path("tests") / f"{name}.rs"
              if not expected.exists():
                  missing.append(str(expected))
          for line in text.splitlines():
              m = re.match(r"\s*-\s+name:\s*(\S+)", line)
              if m:
                  flush(name, optional)
                  total += 1
                  name = m.group(1).strip().strip('"\'')
                  optional = []
                  continue
              mo = re.match(r"\s*optional_for:\s*\[(.*)\]", line)
              if mo:
                  optional = [x.strip().strip('"\'') for x in mo.group(1).split(",") if x.strip()]
          flush(name, optional)
          if missing:
              print(f"::warning::sdk-rust is missing tests for {len(missing)} scenarios after regen:")
              for m in missing:
                  print(f"  - {m}")
          else:
              print(f"All {total} scenarios have corresponding test files (or are exempt for rust).")
          PY
          rm -f test-scenarios.yaml

      - name: Create PR
        id: cpr
        uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
        with:
          token: ${{ steps.app-token.outputs.token }}
          title: "${{ inputs.title || 'chore: regenerate client from updated OpenAPI spec' }}"
          branch: openapi-update-${{ github.run_id }}
          commit-message: "${{ inputs.title || 'chore: regenerate client from OpenAPI spec' }}"
          body: "${{ inputs.summary || 'Auto-generated from updated HotData OpenAPI spec.' }}"