diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc009b0..35eb1cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,34 @@ on: branches: [ main ] jobs: + validate-openapi: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Run OpenAPI lint + run: bash tests/contract/validate_openapi.sh + + verify-openapi: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Verify OpenAPI spec + run: bash tests/contract/verify_openapi.sh + test: runs-on: ubuntu-latest @@ -41,3 +69,4 @@ jobs: - name: Run performance tests run: go test ./tests/performance + diff --git a/.gitignore b/.gitignore index 3f88016..8e3632a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ src/src # Data directory data/ + +# Schemathesis +.hypothesis/ diff --git a/.opencode/command/do-pr.md b/.opencode/command/do-pr.md index e82f8d0..e81fcdf 100644 --- a/.opencode/command/do-pr.md +++ b/.opencode/command/do-pr.md @@ -5,7 +5,11 @@ agent: build IMPORTANT: This command must run the full non-interactive flow for creating a PR. That means it MUST run the test suite(s), commit any changes, push the branch, create the GitHub pull request, update `CHANGELOG.md` with the PR number, and push the changelog — all without asking the user for additional input. -If the user has NOT previously run the `/start-pr` command, prompt them for the issue number to work on. +The user gave the input: "$ARGUMENTS" + +Use the user input as the issue number. + +If the user input is empty or invalid, use the previously entered issue number from `/start-pr` (but if `/start-pr` was not previously ran, prompt the user for the issue number). Required behavior (non-interactive flow) diff --git a/.opencode/command/start-pr.md b/.opencode/command/start-pr.md index da9cfaa..540a12a 100644 --- a/.opencode/command/start-pr.md +++ b/.opencode/command/start-pr.md @@ -14,10 +14,10 @@ If the user input is empty or invalid, prompt the user for the issue number. Required behavior and confirmation flow 1. Read the spec for the given issue in `specs/` and determine the next incomplete section from the Task List. -2. Branch creation rules: - - Create a new branch only when the current branch name does **not** already match the desired `{issue-number}-{section-name}` for the section. - - If the current branch already matches the section, do not create or switch branches. - - If the user explicitly requests to stay on the current branch, do not create a branch. +2. Branch creation rules (agents MUST NOT ask the user about branch behavior): + - Compute branch as `{issue-number}-{slug(section-header)}` where `slug()` lowercases the header, replaces any non‑alphanumeric sequence with `-`, collapses duplicate `-`, and trims leading/trailing `-`. + - If current branch equals OR is very similar to the computed name, do nothing; otherwise create and switch with `git checkout -b ""`. + - If creating/switching would overwrite uncommitted work, warn and request confirmation. 3. Research the codebase to gather information about the change. 4. Ask the user clarifying questions. - Clearly number the questions. diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 0000000..f3f238f --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,7 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redocly.com/docs/cli/ for more information. +specs/openapi.yaml: + no-empty-servers: + - '#/openapi' + operation-4xx-response: + - '#/paths/~1health/get/responses' diff --git a/AGENTS.md b/AGENTS.md index ee7bf11..277c99b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,24 +110,8 @@ See `specs/7-data-persistence.md` for a well-structured specification that: ### Shell quoting when using backticks -- **Problem:** Unescaped backticks in bash commands are interpreted as command substitution, causing the shell to execute the content between backticks instead of passing it as literal text (this can break `gh` calls or insert unintended output). -- **Rule:** When passing text that contains backticks to shell commands, avoid unescaped backticks. Prefer one of these safe patterns: - - Single-quoted argument (simple cases): - - `gh pr edit 56 --body 'Tables: `users`, `events`'` - - Escape backticks inside double quotes: - - `gh pr edit 56 --body "Tables: \`users\`, \`events\`"` - - HEREDOC with single-quoted delimiter (recommended for multi-line bodies or complex content): - - `gh pr edit 56 --body "$(cat <<'EOF'\nTables: `users`, `events`\nEOF\n)"` - -- **Examples:** - - Bad: `gh pr edit 56 --body "Tables: `users`, `events`"` (backticks executed by shell) - - Good (HEREDOC): `gh pr edit 56 --body "$(cat <<'EOF'\nTables: `users`, `events`\nEOF\n)"` - - Good (single quotes): `gh pr edit 56 --body 'Tables: `users`, `events`'` - - Good (escaped): `gh pr edit 56 --body "Tables: \`users\`, \`events\`"` - -- **Recommendation:** Prefer the HEREDOC pattern when generating multi-line PR bodies that include code formatting or backticks. It avoids shell expansion and is easy to read and maintain. - -- Commit messages follow conventional format: `feat:`, `refactor:`, `chore:`, `fix:`, etc. +- Unescaped backticks in bash commands are interpreted as command substitution, causing the shell to execute the content between backticks instead of passing it as literal text (this can break `gh` calls or insert unintended output). +- Prefer the HEREDOC pattern when generating multi-line PR bodies that include code formatting or backticks. It avoids shell expansion and is easy to read and maintain. ### Grep / Ripgrep Patterns @@ -137,15 +121,18 @@ See `specs/7-data-persistence.md` for a well-structured specification that: - Use fixed-string mode for literals: `rg -F 'AddUser('` or `rg --fixed-strings 'AddUser(' - Escape regex metacharacters: `rg 'AddUser\('` (escape `(` with `\` in single-quoted shell strings) - Search the identifier only (no parens): `rg 'AddUser'` - - Prefer single quotes around patterns to avoid shell interpolation: `rg 'GetUserById\('` - - When using the assistant `functions.grep` tool, pass a syntactically valid regex (escape metacharacters) or a simple identifier-only pattern. + - Prefer single quotes around patterns to avoid shell interpolation: `rg 'GetUserById\(' + - When using the `grep` tool, pass a syntactically valid regex (escape metacharacters) or a simple identifier-only pattern. - **Examples:** - Bad: `rg "AddUser("` → causes ripgrep regex parse error (unclosed group) - Good (escape): `rg 'AddUser\('` - - Good (fixed-string): `rg -F 'AddUser(' + - Good (fixed-string): `rg -F 'AddUser('` - Good (identifier only): `rg 'AddUser'` - - **Recommendation:** When programmatically constructing search patterns, either validate the regex before use or default to fixed-string searches. If you are unsure whether a pattern contains regex metacharacters, use `-F` to avoid surprises. +- **Directory scope rule:** When running `rg`, `grep`, or other repository search tools, you MUST specify a relative subdirectory or a specific file path (for example `src/` or `tests/unit/`). You MUST NOT specify a full absolute filesystem path. This prevents searches from scanning unwanted or large directories such as `.git/`, `node_modules/`, or the user's home directory which can produce noisy, slow, or sensitive results. +- **Usage notes:** + - Good: `rg 'AddUser' src/` or `rg -F 'TODO' tests/unit/` or using the assistant `functions.grep` with `path: 'src/'`. + - Bad: `rg 'AddUser' /home/user/repos/kwila/simple-sync` or calling `functions.grep` with `path: '/home/user/...'` (do not use absolute paths). ### PR Title & Description Rules - **Always inspect the full diff for the branch before creating a PR.** Use Git to view changes against the base branch and confirm the final, combined diff that will become the PR. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2803aa9..f02cff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Release History ## [0.4.0] - 2025-10-25 +- [#67](https://github.com/kwila-cloud/simple-sync/pull/67): Add OpenAPI spec and validation contract test - [#62](https://github.com/kwila-cloud/simple-sync/pull/62): Add storage documentation - [#61](https://github.com/kwila-cloud/simple-sync/pull/61): Add performance and concurrency tests for SQLite storage - [#60](https://github.com/kwila-cloud/simple-sync/pull/60): Implement SQLite setup token and API key storage diff --git a/go.mod b/go.mod index 08e78ba..8b0685e 100644 --- a/go.mod +++ b/go.mod @@ -3,37 +3,46 @@ module simple-sync go 1.25 require ( - github.com/gin-gonic/gin v1.9.1 + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 - github.com/stretchr/testify v1.8.4 - golang.org/x/crypto v0.42.0 + github.com/mattn/go-sqlite3 v1.14.15 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.43.0 ) require ( - github.com/bytedance/sonic v1.9.1 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mattn/go-sqlite3 v1.14.15 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect - golang.org/x/arch v0.3.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1d69f41..e1df52e 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,30 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -21,8 +33,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -34,10 +50,16 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -47,8 +69,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -61,28 +89,51 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/specs/18-typescript-sdk.md b/specs/18-typescript-sdk.md index ac2fd65..e2f7123 100644 --- a/specs/18-typescript-sdk.md +++ b/specs/18-typescript-sdk.md @@ -5,26 +5,24 @@ https://github.com/kwila-cloud/simple-sync/issues/18 Build an offline-first TypeScript SDK for the Simple-Sync API and add an OpenAPI spec so we can validate and CI-check SDKs. ### docs: add OpenAPI spec and spec validation tests -- [ ] Add `specs/openapi.yaml` describing the public API endpoints used by SDK (`/events`, `/auth/setup-token/exchange`, `/acl` endpoints used by clients). -- [ ] Add contract tests that validate the OpenAPI spec is loadable and passes linting (e.g., `swagger-cli validate`) under `tests/contract/openapi_spec_test.go`. -- [ ] Add a CI job step to run OpenAPI lint/validate on push and pull requests. - -### feat: add generated TypeScript client scaffold -- [ ] Create directory `clients/typescript` with basic TypeScript project -- [ ] Add prettier config -- [ ] Add eslint config +- [x] Add `specs/openapi.yaml` describing the full public API surface, as described in `docs/src/content/docs/api/v1.md` +- [x] Add contract tests (written in bash) that validate the OpenAPI spec is loadable and passes linting (e.g., `swagger-cli validate`). +- [x] Add a CI job step to run OpenAPI lint/validate on push and pull requests. +- [x] Add contract tests (written in bash) that validate that a locally running instance of the full API matches the specification in `specs/openapi.yaml`. +- [x] Add a CI job step to run the API validation on push and pull requests. +- [ ] Revise based on contract tests failures + +### feat: generate TypeScript SDK +- [ ] Generate typescript SDK from `specs/openapi.yaml`, using [openapi-ts](https://github.com/hey-api/openapi-ts) +- [ ] Add prettier config to the typescript SDK +- [ ] Add eslint config to the typescript SDK - [ ] Run prettier and eslint in CI/CD -### feat: implement full TypeScript SDK -- [ ] Generate typescript SDK from `specs/openapi.yaml` - ### feat: add SDK validation contract tests against test server -- [ ] Add contract tests under `tests/contract/` that start the test server (the repo already has test helpers) and verify the TypeScript SDK behavior against real endpoints (authentication, event post/get, ACL checks). - -- [ ] Ensure tests fail if the OpenAPI spec and server disagree. +- [ ] Add contract tests (in bash) that start the local server and verify the TypeScript SDK behavior against real endpoints (authentication, event post/get, ACL checks). +- [ ] Ensure tests fail if the TypeScript SDK and server disagree. - [ ] Wire these contract tests into CI (separate job that starts the test server and runs Node tests). ### docs: TypeScript SDK -- [ ] Add docs to `docs/` describing the TypeScript SDK example, how to run generation, and how to run the contract tests locally. -- [ ] Add a GitHub Actions workflow (`.github/workflows/sdk-validation.yml`) or extend existing CI to include a job that installs Node, generates the client, builds the example, and runs the contract tests. +- [ ] Add docs to `docs/src/content/docs/sdk/typescript.md` describing the TypeScript SDK diff --git a/specs/openapi.yaml b/specs/openapi.yaml new file mode 100644 index 0000000..3c28cb4 --- /dev/null +++ b/specs/openapi.yaml @@ -0,0 +1,484 @@ +openapi: 3.0.0 +info: + title: Simple-Sync API + version: 1.0.0 + license: + name: MIT + url: https://opensource.org/licenses/MIT + description: | + Simple-Sync is an event storage and ACL-based access control service. This + OpenAPI specification describes the public API surface implemented by the + server and mirrors the documentation in `docs/src/content/docs/api/v1.md`. +paths: + /api/v1/health: + get: + tags: [health] + summary: Health check + operationId: getHealth + description: Returns basic service health information. + security: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + examples: + healthy: + value: + status: healthy + timestamp: '2025-09-22T08:14:09Z' + version: '0.1.0' + uptime: 123 + /api/v1/events: + get: + tags: [events] + operationId: listEvents + summary: List events + description: Returns the authoritative event history visible to the caller. + responses: + '200': + description: A JSON array of event objects + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Not found + '405': + $ref: '#/components/responses/MethodNotAllowed' + post: + tags: [events] + operationId: createEvents + summary: Create events + description: | + Accepts an array of events to append to the store. Events that represent + ACL changes must be submitted via the `/acl` endpoint. The caller must be + authenticated with an API key and have permission to add each event. + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + examples: + sample: + value: + - uuid: "0186e56d-77d0-7000-8003-c289bf62cf41" + timestamp: 1678886402 + user: "user.123" + item: "item.789" + action: "create" + payload: "{}" + responses: + '200': + description: All events after insertion + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + '405': + $ref: '#/components/responses/MethodNotAllowed' + /api/v1/acl: + post: + tags: [acl] + operationId: createAclRules + summary: Submit ACL rules + description: Submit one or more ACL rules. The request is converted into + internal ACL events and stored. Caller must be authenticated and have + `.acl.addRule` permission. + requestBody: + required: true + content: + application/json: + schema: + type: array + minLength: 1 + items: + $ref: '#/components/schemas/AclRule' + examples: + sample: + value: + - user: "user.456" + item: "item.789" + action: "read" + type: "allow" + responses: + '200': + description: ACL events submitted + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: ACL events submitted + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/user/resetKey: + post: + tags: [user] + operationId: resetUserApiKeys + summary: Invalidate a user's API keys + description: Invalidate all existing API keys for the target user. Caller + must be authenticated and have `.user.resetKey` permission for the + target user (or root access). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResetKeyRequest' + examples: + sample: + value: + user: "user.123" + responses: + '200': + description: API keys invalidated + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: API keys invalidated successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/user/generateToken: + post: + tags: [user] + operationId: generateSetupToken + summary: Generate a setup token for a user + description: Generate a short-lived setup token which can be exchanged for + an API key. Caller must be authenticated and have `.user.generateToken` + permission for the target user. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateTokenRequest' + examples: + sample: + value: + user: "user.123" + responses: + '200': + description: Setup token generated + content: + application/json: + schema: + $ref: '#/components/schemas/SetupTokenResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/user/exchangeToken: + post: + tags: [user] + operationId: exchangeSetupToken + summary: Exchange a setup token for an API key + description: Exchange a previously generated setup token for a new API key. + This endpoint is intentionally unauthenticated (used by initial setup + flows). The response contains the generated API key (plain text) once. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + description: Setup token string in the form `XXXX-XXXX` (uppercase letters and digits) + pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}$' + minLength: 9 + maxLength: 9 + example: 'ABCD-1234' + description: + type: string + required: + - token + responses: + '200': + description: API key created from setup token + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangeTokenResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalError' +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: "Use the `X-API-Key` header to pass a user's API key. Format: `sk_` prefix followed by 43 base64 characters (A-Za-z0-9+/), total length 46 characters." + schemas: + Health: + type: object + properties: + status: + type: string + example: healthy + timestamp: + type: string + format: date-time + example: '2025-09-22T08:14:09Z' + version: + type: string + example: '0.1.0' + uptime: + type: integer + description: Uptime in seconds + example: 123 + Event: + type: object + description: Event represents a timestamped event in the system + required: + - uuid + - timestamp + - user + - item + - action + - payload + properties: + uuid: + type: string + format: uuid + description: UUID v7 string identifying the event + timestamp: + type: integer + description: Unix seconds timestamp extracted from the UUID v7 + user: + type: string + description: User identifier owning or creating the event + item: + type: string + description: Item the event applies to (e.g. resource or special items like ".acl") + action: + type: string + description: Action name for the event (internal actions start with `.`) + payload: + type: string + description: JSON-encoded string payload for the event + example: + uuid: "0186e56d-7000-7000-8040-940f030080ad" + timestamp: 1678886400 + user: "user.123" + item: "task.456" + action: "create" + payload: "{}" + AclRule: + type: object + required: + - user + - item + - action + - type + properties: + user: + type: string + description: User pattern (supports `*` suffix wildcard) + item: + type: string + description: Item pattern (supports `*` suffix wildcard; special `.acl` item used internally) + action: + type: string + description: Action pattern (supports `*` suffix wildcard) + type: + type: string + enum: [allow, deny] + description: Whether the rule allows or denies matching actions + example: + user: "user.456" + item: "item.789" + action: "read" + type: "allow" + ResetKeyRequest: + type: object + required: + - user + properties: + user: + type: string + minLength: 1 + description: Target user identifier whose API keys will be invalidated + example: + user: "user.123" + GenerateTokenRequest: + type: object + required: + - user + properties: + user: + type: string + description: Target user identifier to generate a setup token for + minLength: 1 + example: + user: "user.123" + SetupTokenResponse: + type: object + properties: + token: + type: string + description: Setup token string in the form `XXXX-XXXX` (uppercase letters and digits) + pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}$' + minLength: 9 + maxLength: 9 + example: 'ABCD-1234' + expiresAt: + type: string + format: date-time + description: Expiration timestamp of the setup token + example: + token: "ABCD-1234" + expiresAt: '2025-09-26T12:00:00Z' + ExchangeTokenResponse: + type: object + properties: + keyUuid: + type: string + format: uuid + description: UUID for the created API key record + apiKey: + type: string + description: "Plaintext API key value (only returned once). Format: `sk_` prefix followed by 43 base64 characters (A-Za-z0-9+/), total length 46 characters." + pattern: '^sk_[A-Za-z0-9+/]{43}$' + minLength: 46 + maxLength: 46 + user: + type: string + description: + type: string + description: Optional human-friendly description for the key + example: + keyUuid: "0199ab65-1a1e-7000-80f5-23a591c5106e" + apiKey: "sk_abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG" + user: "user.123" + description: "Desktop Client" + responses: + BadRequest: + description: Bad request due to invalid input + content: + application/json: + schema: + type: object + properties: + error: + type: string + text/plain: + schema: + type: string + example: "400 Bad Request" + Unauthorized: + description: Authentication required or invalid credentials + content: + application/json: + schema: + type: object + properties: + error: + type: string + text/plain: + schema: + type: string + example: "X-API-Key header required" + Forbidden: + description: Insufficient permissions + content: + application/json: + schema: + type: object + properties: + error: + type: string + text/plain: + schema: + type: string + example: "Insufficient permissions" + InternalError: + description: Internal server error + content: + application/json: + schema: + type: object + properties: + error: + type: string + text/plain: + schema: + type: string + example: "Internal server error" + MethodNotAllowed: + description: HTTP method not allowed for this path + content: + application/json: + schema: + type: object + properties: + error: + type: string + text/plain: + schema: + type: string + example: "405 method not allowed" + NotAcceptable: + description: Not acceptable - request headers/content not supported + content: + application/json: + schema: + type: object + properties: + error: + type: string + text/plain: + schema: + type: string + example: "Not Acceptable" diff --git a/src/handlers/user.go b/src/handlers/user.go index 7290271..a792bc2 100644 --- a/src/handlers/user.go +++ b/src/handlers/user.go @@ -151,7 +151,7 @@ func (h *Handlers) PostSetupExchangeToken(c *gin.Context) { apiKey, plainKey, err := h.authService.ExchangeSetupToken(request.Token, request.Description) if err != nil { log.Printf("Failed to exchange setup token: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to exchange setup token"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid setup token"}) return } diff --git a/src/main.go b/src/main.go index b636190..afb9c45 100644 --- a/src/main.go +++ b/src/main.go @@ -80,11 +80,19 @@ func main() { // Configure trusted proxies (disable for security in development) router.SetTrustedProxies([]string{}) + // Return 405 for known path but unsupported method + router.HandleMethodNotAllowed = true // Register routes v1 := router.Group("/api/v1") - auth := v1.Group("/") + // Health check route (no middleware) + v1.GET("/health", h.GetHealth) + // Public setup route (no middleware) + v1.POST("/user/exchangeToken", h.PostSetupExchangeToken) + + // Protected group: all routes here require X-API-Key + auth := v1.Group("") auth.Use(middleware.AuthMiddleware(h.AuthService())) auth.GET("/events", h.GetEvents) auth.POST("/events", h.PostEvents) @@ -94,12 +102,6 @@ func main() { auth.POST("/user/resetKey", h.PostUserResetKey) auth.POST("/user/generateToken", h.PostUserGenerateToken) - // Setup routes (no middleware - token-based auth) - v1.POST("/user/exchangeToken", h.PostSetupExchangeToken) - - // Health check route (no middleware) - v1.GET("/health", h.GetHealth) - // Use port from environment configuration port := envConfig.Port diff --git a/tests/contract/validate_openapi.sh b/tests/contract/validate_openapi.sh new file mode 100755 index 0000000..888a9d5 --- /dev/null +++ b/tests/contract/validate_openapi.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Running OpenAPI validation for specs/openapi.yaml" +npx @redocly/cli lint specs/openapi.yaml +echo "OpenAPI validation completed successfully" diff --git a/tests/contract/verify_openapi.sh b/tests/contract/verify_openapi.sh new file mode 100755 index 0000000..bb5325c --- /dev/null +++ b/tests/contract/verify_openapi.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT=8080 +START_TIMEOUT=15 + +# Start the server in the background +PORT=$PORT go run ./src >/dev/null 2>&1 & +PID=$! +PGID=$(ps -o pgid= "$PID" | tr -d ' ' || echo "") + +cleanup() { + rc=${1:-0} + trap - INT TERM EXIT + if [ -n "$PGID" ]; then + kill -- -"$PGID" 2>/dev/null || true + fi + kill "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + exit $rc +} + +trap 'cleanup $?' INT TERM EXIT + +# Wait for readiness +for i in $(seq 1 $START_TIMEOUT); do + if curl -sS --fail "http://localhost:${PORT}/api/v1/health" >/dev/null 2>&1; then + break + fi + sleep 1 + if [ "$i" -eq "$START_TIMEOUT" ]; then + cleanup 1 + fi +done + +set +e +uvx schemathesis run specs/openapi.yaml --url "http://localhost:${PORT}" +SC=$? +set -e + +cleanup $SC