Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .cursor/rules/google-shell-style.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Follow Google Shell Style Guide for Bash scripts
globs: **/*.sh
alwaysApply: false
---

# Google Shell Style Guide

- For Bash scripts, follow the [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html).
- Prefer Bash patterns, naming, quoting, functions, and error-handling conventions from that guide.
- Keep scripts readable and conservative: use `set -euo pipefail` when appropriate, quote expansions, and prefer clear long-form options when available.
- Write new Bash in a style consistent with the guide rather than ad hoc shell habits.

Examples:

```bash
# Prefer
main() {
local branch_name="$1"
git fetch origin --prune
git checkout "${branch_name}"
}

main "$@"

# Avoid
f(){ git fetch origin -p; git checkout $1; }
f $@
```
24 changes: 24 additions & 0 deletions .cursor/rules/long-command-options.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
description: Prefer long-form command-line options
alwaysApply: true
---

# Long-Form Command Options

- Prefer long-form command-line options whenever the tool supports them.
- Avoid short flags such as `-u`, `-p`, `-r`, or `-m` when the equivalent long option is available.
- This applies to shell commands, scripts, documentation examples, and generated command snippets.

Examples:

```bash
# Prefer
git fetch --prune
git push --set-upstream origin HEAD
docker compose up --detach

# Avoid
git fetch -p
git push -u origin HEAD
docker compose up -d
```
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
python-version: "3.13"
cache: pip
cache-dependency-path: apps/api/pyproject.toml

Expand All @@ -43,6 +43,9 @@ jobs:
- name: Test (API 100% line coverage gate)
run: pnpm test:coverage

- name: Pact contract flow
run: pnpm pact:verify
Comment thread
mcalthrop marked this conversation as resolved.

- name: Generate OpenAPI code
run: pnpm openapi:generate

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ htmlcov/
*.egg-info/
dist/
build/

# Pact
apps/web/pacts/
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@
"source.organizeImports.ruff": "explicit"
}
},
"cSpell.words": ["dataclass", "dataclasses"]
"cSpell.words": [
"dataclass",
"dataclasses",
"healthcheck"
]
}
6 changes: 3 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O

## 5. Contract testing (Pact)

- [ ] **5.1** Consumer: add Pact tests for the API usage the UI relies on; publish pacts (targeting your broker URL via secrets in CI).
- [ ] **5.2** Provider: verify the Python API against published pacts in CI; fail the build on verification failures.
- [ ] **5.3** Document self-hosted Pact Broker expectations (URL, auth, tags/branches) and wire GitHub Actions secrets accordingly.
- [x] **5.1** Consumer: add Pact tests for the API usage the UI relies on; publish pacts (targeting your broker URL via secrets in CI).
- [x] **5.2** Provider: verify the Python API against published pacts in CI; fail the build on verification failures.
- [x] **5.3** Document self-hosted Pact Broker expectations (URL, auth, tags/branches) and wire GitHub Actions secrets accordingly.

## 6. CI/CD and local quality gates

Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ You can work in other editors, but Cursor is the intended environment.

The REST API is defined in **`packages/openapi/openapi.yaml`** (shared by the Python API and the front end). From the repository root, **`pnpm lint`** runs **[Redocly](https://redocly.com/docs/cli/)** on that spec (via **`@solid-pact/openapi`**) together with the other workspace lint tasks.

The **CI** workflow (`.github/workflows/ci.yml`) runs **`pnpm lint`**, **`pnpm test`**, **`pnpm openapi:generate`**, and **`pnpm openapi:validate`** (among the other setup steps) on pushes to `main` and on every pull request.
The **CI** workflow (`.github/workflows/ci.yml`) runs **`pnpm lint`**, **`pnpm test`**, **`pnpm pact:verify`**, **`pnpm openapi:generate`**, and **`pnpm openapi:validate`** (among the other setup steps) on pushes to `main` and on every pull request.

When you add or upgrade Node dependencies, run **`pnpm install`** with the **pnpm** version pinned in **`packageManager`** (via Corepack). Using a different pnpm release can rewrite **`pnpm-lock.yaml`** in an incompatible way (for example changing the lockfile format).

Expand Down Expand Up @@ -54,13 +54,17 @@ See [pnpm installation](https://pnpm.io/installation) for other options.

## Scripts (root delegates to workspaces)

All root **`package.json`** scripts delegate to **Turborepo**, which runs the matching script in each workspace (e.g. `apps/web`, `apps/api`). Run them from the repository root with **pnpm** only:
Most root **`package.json`** scripts delegate to **Turborepo**; the Pact scripts target the relevant workspace directly with **pnpm --filter** because only the web and API packages participate in that flow. Run them from the repository root with **pnpm** only:

```bash
pnpm build
pnpm dev
pnpm lint
pnpm test
pnpm pact:consumer-test
pnpm pact:consumer-publish
pnpm pact:provider-verify
pnpm pact:verify
pnpm openapi:generate
pnpm openapi:validate
```
Expand All @@ -77,3 +81,33 @@ Or use Turborepo’s filter directly:
```bash
pnpm turbo build --filter=web
```

## Pact Broker

The repo includes a local self-hosted Pact Broker in **`docker-compose.yml`** (PostgreSQL + Pact Broker). Development defaults:

| Setting | Value |
| ------- | ----- |
| URL | `http://127.0.0.1:9292` |
| Username | `pact` |
| Password | `pact` |

Run the full local flow from the repository root:

```bash
pnpm pact:verify
```

That helper starts Docker Compose, waits for the broker heartbeat, runs the web consumer Pact tests, publishes the generated pacts, verifies the Python provider, then shuts the broker down again.

If you want to run the pieces separately:

```bash
pnpm pact:broker:up
pnpm pact:consumer-test
pnpm pact:consumer-publish
pnpm pact:provider-verify
pnpm pact:broker:down
```

CI uses the same local Docker Compose broker as the manual **`pnpm pact:verify`** command, so it does not require Pact Broker secrets.
1 change: 1 addition & 0 deletions apps/api/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
15 changes: 13 additions & 2 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ FastAPI ASGI service: **`GET /health`**, **`GET /recipes`**, and **`GET /recipes

## Prerequisites

- Python **3.12** or newer
- Python **3.13** or newer
- A virtual environment (recommended)

## Setup
Expand All @@ -21,7 +21,8 @@ If you create the venv yourself (without **`pnpm install`**), install the editab

```bash
cd apps/api
python3 -m venv .venv
rm -rf .venv
python3.13 -m venv .venv
Comment thread
mcalthrop marked this conversation as resolved.
source .venv/bin/activate
pip install -e ".[dev]"
```
Expand All @@ -42,6 +43,16 @@ pnpm test:coverage

The coverage variant runs **`pytest`** with **line coverage** for the **`app`** package, **fails under 100%**, and **omits** generated **`app/openapi/generated/`** (codegen output). Configuration lives in **`pyproject.toml`** (**`[tool.pytest.ini_options]`**, **`[tool.coverage.*]`**).

Provider contract verification:

```bash
pnpm pact:provider-verify
```
Comment on lines +46 to +50

If **`PACT_BROKER_BASE_URL`** is set, the verifier pulls contracts from that broker and publishes verification results in CI. Without broker settings, it falls back to local pact files under **`apps/web/pacts/`** so you can run the consumer + provider loop locally after **`pnpm pact:consumer-test`**.

In the current GitHub Actions workflow, the full Pact flow is exercised via the root **`pnpm pact:verify`** helper, which starts the local broker from **`docker-compose.yml`** before publishing and verifying.

## Import order (Ruff / isort)

Imports are checked with **[Ruff](https://docs.astral.sh/ruff/)** using the **`I`** rules (PEP 8–style ordering compatible with **isort**). Configuration lives under **`[tool.ruff]`** in **`pyproject.toml`** (`known-first-party = ["app"]`; generated OpenAPI models under **`app/openapi/generated/`** are excluded). **Ruff** is a **dev** dependency; after **[Setup](#setup)** ( **`pnpm install`** or **`pip install -e ".[dev]"`** ), **`apps/api/.venv/bin/ruff`** is available.
Expand Down
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
"openapi:validate": "bash scripts/validate_openapi_generated.sh",
"lint": "pnpm build && .venv/bin/ruff check app",
"lint:fix": ".venv/bin/ruff check app --fix",
"pact:provider-verify": "bash scripts/pact-provider-verify.sh",
"test": ".venv/bin/python -m pytest -v",
"test:coverage": "pnpm test --cov=app --cov-report=term-missing",
"postinstall": "bash -e -c 'test -d .venv || python3 -m venv .venv; .venv/bin/python -m pip install -e \".[dev]\"'"
},
"devDependencies": {
"@pact-foundation/pact-cli": "18.0.0"
}
}
2 changes: 1 addition & 1 deletion apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "recipes-api"
version = "0.1.0"
description = "Bread Recipes REST API (FastAPI)"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.13"
dependencies = [
"datamodel-code-generator==0.26.5",
"fastapi==0.115.12",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"include": ["app"],
"exclude": [".venv", "**/__pycache__"],
"pythonVersion": "3.12",
"pythonVersion": "3.13",
"venvPath": ".",
"venv": ".venv"
}
111 changes: 111 additions & 0 deletions apps/api/scripts/pact-provider-verify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env bash

set -euo pipefail

script_dir="$(cd "$(dirname "$0")" && pwd)"
readonly script_dir

package_root="$(cd "${script_dir}/.." && pwd)"
readonly package_root

repo_root="$(cd "${package_root}/../.." && pwd)"
readonly repo_root

provider_name="${PACT_PROVIDER_NAME:?Missing required environment variable: PACT_PROVIDER_NAME}"
readonly provider_name

provider_base_url="${PACT_PROVIDER_BASE_URL:?Missing required environment variable: PACT_PROVIDER_BASE_URL}"
readonly provider_base_url

provider_hostname="${PACT_PROVIDER_HOSTNAME:?Missing required environment variable: PACT_PROVIDER_HOSTNAME}"
readonly provider_hostname

provider_port="${PACT_PROVIDER_PORT:?Missing required environment variable: PACT_PROVIDER_PORT}"
readonly provider_port

provider_transport="${PACT_PROVIDER_TRANSPORT:?Missing required environment variable: PACT_PROVIDER_TRANSPORT}"
readonly provider_transport

wait_for_healthy_provider() {
local healthcheck_url="${provider_base_url}/health"
local attempt

for ((attempt = 1; attempt <= 60; attempt += 1)); do
if wget --quiet --output-document=/dev/null "${healthcheck_url}"; then
Comment thread
mcalthrop marked this conversation as resolved.
return
fi
sleep 0.5
done

echo "Provider did not become healthy at ${healthcheck_url}" >&2
exit 1
}

cleanup() {
if [[ -n "${provider_pid:-}" ]]; then
kill -TERM "${provider_pid}" 2>/dev/null || true
wait "${provider_pid}" 2>/dev/null || true
fi
}

run_local_verification() {
local command=(
"${package_root}/node_modules/.bin/pact-verifier"
--dir "${repo_root}/apps/web/pacts"
--provider-name "${provider_name}"
--hostname "${provider_hostname}"
--port "${provider_port}"
--transport "${provider_transport}"
)

"${command[@]}"
}

run_broker_verification() {
local command

command=(
"${package_root}/node_modules/.bin/pact-verifier"
--broker-url "${PACT_BROKER_BASE_URL}"
--provider-name "${provider_name}"
--hostname "${provider_hostname}"
--port "${provider_port}"
--transport "${provider_transport}"
--consumer-version-selectors '{"mainBranch":true}'
--consumer-version-selectors '{"matchingBranch":true}'
--enable-pending
--provider-branch "${PACT_PROVIDER_BRANCH:?Missing required environment variable: PACT_PROVIDER_BRANCH}"
--user "${PACT_BROKER_USERNAME:?Missing required environment variable: PACT_BROKER_USERNAME}"
--password "${PACT_BROKER_PASSWORD:?Missing required environment variable: PACT_BROKER_PASSWORD}"
)

if [[ "${CI:-}" == "true" ]]; then
command+=(
--publish
--provider-version "${PACT_PROVIDER_VERSION:?Missing required environment variable: PACT_PROVIDER_VERSION}"
)
fi

"${command[@]}"
}

main() {
cd "${package_root}"

./.venv/bin/python -m uvicorn app.main:app --host "${provider_hostname}" --port "${provider_port}" &
provider_pid=$!
readonly provider_pid

trap cleanup EXIT

wait_for_healthy_provider

if [[ -n "${PACT_BROKER_BASE_URL:-}" ]]; then
run_broker_verification
return
fi

run_local_verification
}

main "$@"
23 changes: 23 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Run from **`apps/web`** (or via **`pnpm --filter web <script>`** from the root):
| **`pnpm lint:fix`**| Biome with **`--write`** (fix + format) |
| **`pnpm test`** | Vitest once (`vitest run`) |
| **`pnpm test:watch`** | Vitest watch |
| **`pnpm pact:consumer-test`** | Runs the dedicated Pact consumer tests and writes pact files to **`pacts/`**. |
| **`pnpm pact:consumer-publish`** | Publishes the generated pact files to the broker configured by environment variables. |

Configuration: **`biome.json`** (Biome **2.4.x**), **`vite.config.ts`** (includes Vitest), **`tsconfig.*.json`**.

Expand All @@ -51,3 +53,24 @@ The UI uses a **full fetch-based client** generated from **`../../packages/opena
| **`pnpm openapi:validate`** | Runs **`redocly lint`** on **`packages/openapi/openapi.yaml`** via **`@solid-pact/openapi`** (same as **`pnpm --filter @solid-pact/openapi run lint`**). |

Import the wrapped SDK from **`src/api/index.ts`** (e.g. **`listRecipes`**, **`getRecipeById`**, **`apiClient`**, and schema types). By default, **`VITE_API_BASE_URL`** is unset in production builds (same-origin requests). In **`pnpm dev`**, the client targets **`http://127.0.0.1:8000`** unless you set **`VITE_API_BASE_URL`**. See **`.env.example`**.

## Pact consumer contracts

Consumer Pact tests live under **`pact/`** and run with a separate Vitest config so they exercise the generated API client in a Node environment rather than jsdom.

Generate pact files locally:

```bash
pnpm pact:consumer-test
```

Publish those pact files to a broker:

```bash
PACT_BROKER_BASE_URL=http://127.0.0.1:9292 \
PACT_BROKER_USERNAME=pact \
PACT_BROKER_PASSWORD=pact \
pnpm pact:consumer-publish
```

CI uses the same publish script, passing **`PACT_CONSUMER_VERSION`** and **`PACT_CONSUMER_BRANCH`** from GitHub metadata.
2 changes: 1 addition & 1 deletion apps/web/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/src/api/generated"]
"includes": ["**", "!**/src/api/generated", "!pacts"]
},
"formatter": {
"enabled": true,
Expand Down
Loading
Loading