Skip to content
Merged
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
115 changes: 75 additions & 40 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,77 +1,112 @@
# Docs: publish the mkdocs site to GitHub Pages on every push to main.
# Docs: build the versioned documentation site with mike (Material for MkDocs)
# and publish it to the `gh-pages` branch, which GitHub Pages serves.
#
# One-time repository setup (Settings -> Pages):
# Source: "GitHub Actions".
# Source: "Deploy from a branch" -> branch `gh-pages`, folder `/ (root)`.
#
# The build also runs in `ci.yml`, on every pull request, so documentation
# breakage is caught before it reaches main rather than at the deploy.
# This replaced an `upload-pages-artifact` + `deploy-pages` pair, and with it the
# "Source: GitHub Actions" setting. mike needs a branch it owns: it keeps one
# built site per version there and maintains the `versions.json` the header
# switcher reads, neither of which survives a model where every deploy replaces
# the whole artifact.
#
# Deployed through the Pages actions rather than by pushing a `gh-pages`
# branch: no branch to maintain, and the artifact is what Pages serves.
# Versioning:
# - A final release tag (`v1.2.3`) publishes that release under its bare
# version number and moves the `latest` alias, which the site root redirects
# to.
# - A PRE-RELEASE tag (`v1.2.3-rc.1`, or any tag with a `-` qualifier)
# publishes nothing. Release candidates are cut from `develop` to exercise
# the release pipeline; their documentation is in-progress documentation,
# already covered by `dev`, and publishing it only adds throwaway entries to
# the switcher that someone then has to delete by hand.
# - A push to `main` publishes the in-progress documentation under `dev`.
# - `workflow_dispatch` re-runs whatever ref it is pointed at.
#
# Not versioned. `mike` would put a version switcher in the header and keep a
# page per release, which is worth doing once there are releases to switch
# between; there are none yet, and an empty switcher is worse than none. When
# the first tag lands, add `extra.version.provider: mike` to `mkdocs.yml` and
# replace the build step below with `mike deploy`.
# The strict build also runs in `ci.yml` on every pull request, so documentation
# breakage is caught before it reaches main rather than at the deploy.

name: Docs

on:
push:
branches:
- main
tags:
# Final releases only: the negative pattern excludes any pre-release tag
# (a `-` qualifier), so an RC never starts this workflow at all.
- "v*"
- "!v*-*"
workflow_dispatch:

# mike pushes commits to the gh-pages branch. No `pages: write` or
# `id-token: write` any more -- nothing here talks to the Pages deployment API.
permissions:
contents: read
pages: write
# Required by actions/deploy-pages: it exchanges this for a deployment token
# rather than using a stored secret.
id-token: write
contents: write

# One deploy at a time, and do not cancel one that is already publishing --
# an interrupted deployment can leave Pages serving a half-uploaded site.
# Serialize deploys so two runs never race on pushing gh-pages. Not cancelled
# in progress: an interrupted mike run can leave the branch with a site
# committed and its `versions.json` not yet updated.
concurrency:
group: pages
group: docs-deploy
cancel-in-progress: false

jobs:
build:
name: Build the site
deploy:
name: Build and deploy versioned docs
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# mike reads and rewrites the gh-pages branch, so it needs history
# rather than the default shallow clone of one ref.
fetch-depth: 0

# Pinned to an exact release, unlike every other action here. astral-sh
# publishes `v9.0.0` but stopped publishing the sliding major tag after
# `v7`, so `@v9` resolves to nothing and the job fails before it runs.
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0

- name: Build the site (strict)
run: uv run --with-requirements docs/requirements.txt mkdocs build --strict
- name: Configure git identity for mike
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Upload the site as a Pages artifact
uses: actions/upload-pages-artifact@v5
with:
# `site/` is what mkdocs writes and what .gitignore excludes.
path: site
# `|| true`: the branch does not exist before the first deploy, and mike
# creates it. A missing branch is not a failure here.
- name: Make the gh-pages branch available to mike
run: git fetch origin gh-pages --depth=1 || true

deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
- name: Deploy
run: |
set -euo pipefail
mike() {
uv run --with-requirements docs/requirements.txt mike "$@"
}

environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "Publishing development docs (alias: dev)"
mike deploy --push --update-aliases dev
exit 0
fi

steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v5
version="${GITHUB_REF_NAME#v}"
# The tag filter above already keeps pre-releases out, but
# workflow_dispatch can be pointed at any ref -- so refuse here too
# rather than trusting the trigger alone.
case "${version}" in
*-*)
echo "${version} is a pre-release; skipping docs publication."
exit 0
;;
esac

echo "Publishing release docs ${version} (alias: latest)"
mike deploy --push --update-aliases "${version}" latest
# Idempotent, and cheap enough to repeat: it writes the redirect at the
# site root. Doing it on every release means a gh-pages branch that was
# ever rebuilt by hand does not need this done separately.
mike set-default --push latest
53 changes: 53 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,59 @@ release promotes it to a version heading; the `bump-version` skill does that.

## Development

### Changed

- The documentation site is versioned with `mike`: one built copy per release on
the `gh-pages` branch, a switcher in the header, and the site root redirecting
to the newest release. It was a single unversioned site, so a reader on 0.1.0
had no way to reach the documentation for the release they were running, and
publishing 0.2.0 silently replaced it. Pre-release tags publish nothing; a push
to `main` publishes `dev`.

## 0.2.0 -- 2026-08-03

### Changed

- A matching mock is now decided before `loss` and `latency`, not after, so
`replace` is the share of matching requests a mock answers rather than the
share of those that survived a loss roll. Previously `loss: 0.5` halved every
`replace` in the proxy, and no configuration could ask a mock to answer half
of its matching requests while any loss was set. The proxy's `loss` and
`latency` no longer apply to a request a mock answered: they describe the real
backend, and a mock replaces it.
- A run of slashes at the start of a request path is collapsed to one before
mocks are matched, so `//api/v1/index/` matches a mock declared
`^/api/v1/index/$`. Clients produce the doubled form by joining a base URL
ending in `/` to a path beginning with `/`; it is legal HTTP, nothing rejected
it, and the only symptom was an anchored mock silently not firing. Empty
segments elsewhere in the path are left alone.
- An injected `latency` is now a target for the whole response rather than an
addition to it: the time the upstream really took is subtracted, and only the
remainder is waited out. A 500ms latency in front of a backend answering in
120ms delays by 380ms, where before it delayed by 500 and produced 620ms
total -- so the number written in the configuration was unreachable by
construction, and moved with whatever the upstream happened to be doing. An
upstream slower than the target leaves no remainder and is passed straight
through; the setting is a floor, never a ceiling. `latency_injected_ms` in the
log line is the wait actually taken and reads `0` in that case, while
`doppel_latency_injected_total` still counts the request.

### Fixed

- A mock's `proxy.loss` and `proxy.latency` are applied. They were parsed,
validated and compiled into the runtime, and then never read, so a mock
declaring either was silently answering every request it matched. They now
apply to the requests the mock answers, after it has won its `replace` roll,
and go through the same `decide` as the proxy's -- so loss short-circuits
latency there too.
- What a mock inherits from its proxy is now settled per setting rather than by
accident: `replace` and `latency` fall back to the proxy's, `loss` does not.
`latency` describes how slow the proxy is to answer, which holds whatever
answers, so a mocked response is delayed like any other and a mock's own value
overrides rather than adds to it. `loss` is excluded because a mock inheriting
it would be dropped by the proxy's loss, which is the coupling between `loss`
and `replace` the ordering above exists to remove.

## 0.1.0 -- 2026-08-02

The first release. Everything below is new, so the sections are what a reader
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "3"

[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
Expand Down
83 changes: 83 additions & 0 deletions crates/doppel-proxy/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ use doppel_render::Variables;
/// declaration order distinguishes the two. Do not "fix" this by anchoring
/// the pattern here -- doing so would silently change what an existing,
/// unedited configuration means.
///
/// The path is matched with any run of leading slashes collapsed to one, so a
/// request for `//api/v1/index/` is matched as `/api/v1/index/`. See
/// [`for_matching`].
pub fn match_mock<'a>(
proxy: &'a CompiledProxy,
method: &Method,
path: &str,
) -> Option<(&'a CompiledMock, Variables)> {
let path = for_matching(path);
proxy.mocks.iter().find_map(|mock| {
// `as_str` on both sides, and no case folding: a mock can only
// declare a method the type knows, and an incoming method that is
Expand All @@ -45,6 +50,36 @@ pub fn match_mock<'a>(
})
}

/// The request path as mock patterns see it: any run of leading slashes
/// collapsed to a single one.
///
/// A request line of `GET //api/v1/index/` is legal HTTP, and clients produce
/// it by accident all the time -- a base URL ending in `/` joined to a path
/// beginning with `/` is the usual way. Nothing in the path's meaning changed,
/// but a pattern written `^/api/v1/index/$` no longer matched it, and the
/// request fell through to the upstream with no sign of why. Matching one slash
/// where the client sent several removes a class of "the mock just does not
/// fire" that has no diagnosis short of reading the request line.
///
/// Only *leading* slashes. An empty segment in the middle of a path (`/a//b`)
/// is a segment, and collapsing it would be a claim about what the upstream
/// considers the same resource -- which is the upstream's to make, not this
/// function's. A borrow is returned when there is nothing to collapse, so the
/// common path allocates nothing.
fn for_matching(path: &str) -> &str {
match path.strip_prefix('/') {
// `trim_start_matches` on the remainder rather than on `path`, so the
// one slash the path is entitled to survives.
Some(rest) => {
let trimmed = rest.trim_start_matches('/');
// Byte arithmetic on a `str`: every byte removed was `/`, which is
// ASCII, so the split cannot land inside a character.
&path[path.len() - trimmed.len() - 1..]
}
None => path,
}
}

/// Binds the mock's declared header variables. A header the request does not
/// carry binds nothing -- referencing it in a template is an undefined
/// variable, per section 5 of the design.
Expand Down Expand Up @@ -185,6 +220,54 @@ mod tests {
assert_eq!(matched.name, "m1");
}

/// An anchored pattern is the case that made this worth fixing: an
/// unanchored `/api/v1/index/` already matched `//api/v1/index/`, because
/// the doubled slash sits outside the substring it looks for. `^`-anchored,
/// it did not, and nothing said why.
#[test]
fn repeated_leading_slashes_match_an_anchored_pattern_written_with_one() {
let p = proxy(vec![mock("m1", "GET", "^/api/v1/index/$")]);
let (matched, _vars) = match_mock(&p, &Method::GET, "//api/v1/index/").unwrap();
assert_eq!(matched.name, "m1");
}

#[test]
fn any_number_of_leading_slashes_collapses_to_one() {
let p = proxy(vec![mock("m1", "GET", "^/widgets/$")]);
for path in ["/widgets/", "//widgets/", "///widgets/", "/////widgets/"] {
assert!(
match_mock(&p, &Method::GET, path).is_some(),
"`{path}` should have matched"
);
}
}

/// Only the leading run. An empty segment in the middle of a path is a
/// segment, and whether `/a//b` and `/a/b` name the same resource is the
/// upstream's business -- collapsing it here would answer that question on
/// the upstream's behalf, for mocks only, and disagree with what gets
/// forwarded.
#[test]
fn an_empty_segment_inside_the_path_is_left_alone() {
let p = proxy(vec![mock("m1", "GET", "^/a/b/$")]);
assert!(match_mock(&p, &Method::GET, "/a//b/").is_none());
}

#[test]
fn for_matching_leaves_a_path_that_needs_nothing_untouched() {
// Also covers the degenerate paths, where an off-by-one in the slice
// arithmetic would panic or eat the last slash rather than merely
// return the wrong string.
assert_eq!(for_matching("/widgets/"), "/widgets/");
assert_eq!(for_matching("/"), "/");
assert_eq!(for_matching("//"), "/");
assert_eq!(for_matching("///"), "/");
assert_eq!(for_matching(""), "");
// A request target that is not origin-form reaches this function
// unchanged; there is no leading slash to collapse.
assert_eq!(for_matching("widgets/"), "widgets/");
}

/// Fixture names are adversarial on purpose: `zeta` is declared first and
/// `alpha` second, so a regression that sorted mocks (alphabetically, or
/// by any other accidental order) rather than preserving declaration
Expand Down
Loading
Loading