diff --git a/Cargo.toml b/Cargo.toml index 46154239..0542ea42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,12 +22,14 @@ serde = { version = "1.0", features = ["derive"], optional = true } serde_regex = { version = "1.1", optional = true } fnv = "1" bitflags = { version = "2.6", optional = true } +atc_router_prefilter = { version = "1.0", path = "crates/atc_router_prefilter" } [dev-dependencies] criterion = "0" [lib] crate-type = ["lib", "cdylib", "staticlib"] +bench = false [features] default = ["ffi"] @@ -42,3 +44,10 @@ harness = false [[bench]] name = "match_mix" harness = false + +[[bench]] +name = "worst_case" +harness = false + +[workspace] +members = ["crates/*"] \ No newline at end of file diff --git a/README.md b/README.md index cc441902..87d5703e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ ATC Router library for Kong. * [new](#new) * [add\_matcher](#add_matcher) * [remove\_matcher](#remove_matcher) + * [enable\_prefilter](#enable_prefilter) + * [disable\_prefilter](#disable_prefilter) * [execute](#execute) * [get\_fields](#get_fields) * [validate](#validate) @@ -165,6 +167,36 @@ matcher does not exist. [Back to TOC](#table-of-contents) +### enable\_prefilter + +**syntax:** *res, err = r:enable_prefilter(field)* + +**context:** *any* + +Enables prefiltering on the specified `field`. The field must be of type `String` +in the router's schema. When enabled, the router uses the prefilter to narrow down +candidate matchers before performing full evaluation, which can improve match +performance. + +Calling this method requires building a prefilter for all current matchers: it may be +expensive. It is expected that this method will be called only once for a router. The +prefilter will be kept up to date with any added and removed matchers, until this method +is called again with a different field name, or the disable_prefilter method is called. + +If an error occurred, `nil` and a string describing the error will be returned. + +[Back to TOC](#table-of-contents) + +### disable\_prefilter + +**syntax:** *r:disable_prefilter()* + +**context:** *any* + +Disables prefiltering on the router, reverting to the default matching behavior. + +[Back to TOC](#table-of-contents) + ### execute **syntax:** *res, err = r:execute(context)* diff --git a/benches/build.rs b/benches/build.rs index 2ff8c54e..e0a8b29f 100644 --- a/benches/build.rs +++ b/benches/build.rs @@ -1,7 +1,7 @@ use atc_router::ast::Type; use atc_router::router::Router; use atc_router::schema::Schema; -use criterion::{criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use uuid::Uuid; // To run this benchmark, execute the following command: @@ -9,7 +9,7 @@ use uuid::Uuid; // cargo bench --bench build // ``` -const N: usize = 1000; +const N: usize = 5000; fn make_uuid(a: usize) -> String { format!("8cb2a7d0-c775-4ed9-989f-{:012}", a) @@ -24,23 +24,50 @@ fn criterion_benchmark(c: &mut Criterion) { let uuid = make_uuid(i); let uuid = Uuid::try_from(uuid.as_str()).unwrap(); - let expr = format!("((a > 0 || a < {}) && a != 0) && a == 1", N + 1); + let expr = format!( + "((a > 0 || a < {0}) && a != 0) && a == 1 && b == \"{0}\"", + N + 1 + ); data.push((priority, uuid, expr)) } let mut schema = Schema::default(); schema.add_field("a", Type::Int); + schema.add_field("b", Type::String); - c.bench_function("Build Router", |b| { - b.iter_with_large_drop(|| { - let mut router = Router::new(&schema); - for v in &data { - router.add_matcher(v.0, v.1, &v.2).unwrap(); - } - router - }); - }); + let mut g = c.benchmark_group("build router"); + for n in [1, 10, 100, 500, 1000, 3000, N] { + g.throughput(Throughput::Elements(n as u64)); + g.bench_with_input( + BenchmarkId::new("without prefilter", n), + &data[..n], + |b, data| { + b.iter_with_large_drop(|| { + let mut router = Router::new(&schema); + for v in data { + router.add_matcher(v.0, v.1, &v.2).unwrap(); + } + router + }); + }, + ); + + g.bench_with_input( + BenchmarkId::new("with prefilter", n), + &data[..n], + |b, data| { + b.iter_with_large_drop(|| { + let mut router = Router::new(&schema); + router.enable_prefilter("b"); + for v in data { + router.add_matcher(v.0, v.1, &v.2).unwrap(); + } + router + }); + }, + ); + } } criterion_group!(benches, criterion_benchmark); diff --git a/benches/match_mix.rs b/benches/match_mix.rs index 25e8aa3a..eefca8b3 100644 --- a/benches/match_mix.rs +++ b/benches/match_mix.rs @@ -2,14 +2,9 @@ use atc_router::ast::{Type, Value}; use atc_router::context::Context; use atc_router::router::Router; use atc_router::schema::Schema; -use criterion::{criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; use uuid::Uuid; -// To run this benchmark, execute the following command: -// ```shell -// cargo bench --bench match_mix -// ``` - const N: usize = 100_000; fn make_uuid(a: usize) -> String { @@ -26,8 +21,8 @@ fn criterion_benchmark(c: &mut Criterion) { for i in 0..N { let expr = format!( - r#"(http.path == "hello{}" && http.version == "1.1") || {} || {} || {}"#, - i, "!((a == 2) && (a == 9))", "!(a == 1)", "(a == 3 && a == 4) && !(a == 5)" + r#"(http.path == "hello{}" && http.version == "1.1") && ({} || {} || {}) && {}"#, + i, "!((a == 2) && (a == 9))", "!(a == 1)", "(a == 3 && a == 4)", "!(a == 5)" ); let uuid = make_uuid(i); @@ -36,33 +31,44 @@ fn criterion_benchmark(c: &mut Criterion) { router.add_matcher(N - i, uuid, &expr).unwrap(); } + let mut g = c.benchmark_group("match_mix"); + let mut ctx = Context::new(&schema); // match benchmark - ctx.add_value("http.path", Value::String("hello49999".to_string())); - ctx.add_value("http.version", Value::String("1.1".to_string())); - ctx.add_value("a", Value::Int(3_i64)); + const NUMBERS: &[usize] = &[0, 10, 1_000, 10_000, 40_000, 70_000, N - 1, N + 1]; + for &i in NUMBERS { + ctx.reset(); + ctx.add_value("http.path", Value::String(format!("hello{}", i))); + ctx.add_value("http.version", Value::String("1.1".to_string())); + ctx.add_value("a", Value::Int(3_i64)); - c.bench_function("Match", |b| { - b.iter(|| { - let is_match = router.execute(&mut ctx); - assert!(is_match); + let expected_match = i < N; + g.bench_with_input(BenchmarkId::new("without prefilter", i), &i, |b, _| { + b.iter(|| { + let is_match = router.execute(&mut ctx); + assert_eq!(is_match, expected_match); + }); }); - }); + } - ctx.reset(); + router.enable_prefilter("http.path"); - // not match benchmark - ctx.add_value("http.path", Value::String("hello49999".to_string())); - ctx.add_value("http.version", Value::String("1.1".to_string())); - ctx.add_value("a", Value::Int(5_i64)); // not match + for &i in NUMBERS { + ctx.reset(); + ctx.add_value("http.path", Value::String(format!("hello{}", i))); + ctx.add_value("http.version", Value::String("1.1".to_string())); + ctx.add_value("a", Value::Int(3_i64)); - c.bench_function("Doesn't Match", |b| { - b.iter(|| { - let not_match = !router.execute(&mut ctx); - assert!(not_match); + let expected_match = i < N; + g.bench_with_input(BenchmarkId::new("with prefilter", i), &i, |b, _| { + b.iter(|| { + let is_match = router.execute(&mut ctx); + assert_eq!(is_match, expected_match); + }); }); - }); + } + g.finish(); } criterion_group!(benches, criterion_benchmark); diff --git a/benches/worst_case.rs b/benches/worst_case.rs new file mode 100644 index 00000000..2330295c --- /dev/null +++ b/benches/worst_case.rs @@ -0,0 +1,104 @@ +use atc_router::ast::{Type, Value}; +use atc_router::context::Context; +use atc_router::router::Router; +use atc_router::schema::Schema; +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use uuid::Uuid; + +fn make_uuid(a: usize) -> String { + format!("8cb2a7d0-c775-4ed9-989f-{:012}", a) +} + +fn worst_case(c: &mut Criterion) { + const MAX: usize = 2_000; + let mut schema = Schema::default(); + schema.add_field("http.path", Type::String); + + // This is the worst case for the prefilter, we end up finding prefixes as + // '/a', '/a/a', '/a/a/a', etc, which means a string that starts with `/a/a/a/...` also + // starts with `/a/a/...`, and also starts with `/a/...` etc. + let all_matchers: Vec<(usize, Uuid, String)> = (0..MAX) + .map(|i| { + let expression_str = format!("http.path == r#\"{}\"#", "/a".repeat(i)); + (MAX - i, make_uuid(i).parse().unwrap(), expression_str) + }) + .collect(); + + let mut g = c.benchmark_group("worst-case build"); + for n in [1, 10, 100, 500, 1000, MAX] { + // Get the _last_ matchers, to avoid both growing the number of matchers, and the size of + // the matchers + let matchers = all_matchers.windows(n).last().unwrap(); + g.throughput(Throughput::Elements(n as u64)); + g.bench_with_input( + BenchmarkId::new("without prefilter", n), + matchers, + |b, matchers| { + b.iter_with_large_drop(|| { + let mut router = Router::new(&schema); + for &(priority, uuid, ref expression) in matchers { + router.add_matcher(priority, uuid, expression).unwrap(); + } + router + }); + }, + ); + + g.bench_with_input( + BenchmarkId::new("with prefilter", n), + matchers, + |b, matchers| { + b.iter_with_large_drop(|| { + let mut router = Router::new(&schema); + router.enable_prefilter("http.path"); + for &(priority, uuid, ref expression) in matchers { + router.add_matcher(priority, uuid, expression).unwrap(); + } + router + }); + }, + ); + } + g.finish(); + + let mut router = Router::new(&schema); + for (priority, uuid, expression) in all_matchers { + router.add_matcher(priority, uuid, &expression).unwrap(); + } + let mut ctx = Context::new(&schema); + let mut g = c.benchmark_group("worst-case match"); + for n in [1, 10, 100, 500, 1000, MAX] { + ctx.reset(); + ctx.add_value("http.path", Value::String("/a".repeat(n - 1))); + g.bench_with_input( + BenchmarkId::new("without prefilter", n), + &router, + |b, router| { + b.iter(|| { + let found = router.execute(&mut ctx); + assert!(found); + found + }) + }, + ); + } + router.enable_prefilter("http.path"); + for n in [1, 10, 100, 500, 1000, MAX] { + ctx.reset(); + ctx.add_value("http.path", Value::String("/a".repeat(n - 1))); + g.bench_with_input( + BenchmarkId::new("with prefilter", n), + &router, + |b, router| { + b.iter(|| { + let found = router.execute(&mut ctx); + assert!(found); + found + }) + }, + ); + } +} + +criterion_group!(benches, worst_case); +criterion_main!(benches); diff --git a/crates/atc_router_prefilter/.gitignore b/crates/atc_router_prefilter/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/crates/atc_router_prefilter/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/atc_router_prefilter/CHANGELOG.md b/crates/atc_router_prefilter/CHANGELOG.md new file mode 100644 index 00000000..c24a507f --- /dev/null +++ b/crates/atc_router_prefilter/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.3.3](https://github.com/Dr-Emann/router-prefilter/compare/v1.3.2...v1.3.3) - 2026-03-03 + +### Other + +- add unit tests for intersect_prefix_expansions_into function +- add docs about what intersecting with prefix expansion means/does + +## [1.3.2](https://github.com/Dr-Emann/router-prefilter/compare/v1.3.1...v1.3.2) - 2026-02-16 + +### Other + +- add more documentation on how to call the visitor functions + +## [1.3.1](https://github.com/Dr-Emann/router-prefilter/compare/v1.3.0...v1.3.1) - 2026-02-13 + +### Other + +- *(deps)* update rand to published 0.10 version in dev dependencies + +## [1.3.0](https://github.com/Dr-Emann/router-prefilter/compare/v1.2.0...v1.3.0) - 2026-02-08 + +### Other + +- replace BTreeMap-based implmementation with a radix trie + +## [1.2.0](https://github.com/Dr-Emann/router-prefilter/compare/v1.1.1...v1.2.0) - 2026-02-06 + +### Added + +- implement more iterator traits for RouterPrefilterIter + +### Fixed + +- handle duplicate key insertions + +### Other + +- fix compilation of readme example by using as the library doc comment + +## [1.1.1](https://github.com/Dr-Emann/router-prefilter/compare/v1.1.0...v1.1.1) - 2026-02-06 + +### Other + +- optimize insert + +## [1.1.0](https://github.com/Dr-Emann/router-prefilter/compare/v1.0.2...v1.1.0) - 2026-02-03 + +### Added + +- add RouterPrefilter::clear method + +## [1.0.2](https://github.com/Dr-Emann/router-prefilter/compare/v1.0.1...v1.0.2) - 2026-02-02 + +### Other + +- Update docs + +## [1.0.1](https://github.com/Dr-Emann/router-prefilter/compare/v1.0.0...v1.0.1) - 2026-02-02 + +### Other + +- updates for package repo diff --git a/crates/atc_router_prefilter/Cargo.toml b/crates/atc_router_prefilter/Cargo.toml new file mode 100644 index 00000000..82898996 --- /dev/null +++ b/crates/atc_router_prefilter/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "atc_router_prefilter" +version = "1.3.3" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "Fast prefix-based prefiltering for router pattern matching. Kong's fork of router_prefilter." +categories = ["data-structures"] +keywords = ["router", "routing", "prefilter", "http", "performance"] +authors = ["Zachary Dremann "] +repository = "https://github.com/Kong/atc-router" + +[dependencies] +regex-syntax = "0.8" +bstr = "1.12" + +[dev-dependencies] +criterion = { version = "0.8.1", features = ["html_reports"] } +rand = "0.10.0" +regex = "1.12.2" + +[[bench]] +name = "router_bench" +harness = false diff --git a/crates/atc_router_prefilter/README.md b/crates/atc_router_prefilter/README.md new file mode 100644 index 00000000..1f9e12f1 --- /dev/null +++ b/crates/atc_router_prefilter/README.md @@ -0,0 +1,50 @@ +# atc_router_prefilter + +Kong's fork of [`router_prefilter`]. + +Fast prefix-based prefiltering for router pattern matching. + +This crate provides efficient prefiltering of route matchers by extracting and indexing literal prefixes from patterns. + +## Usage + +```rust +use atc_router_prefilter::RouterPrefilter; +use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + +struct RoutePattern { + regex: String, +} + +impl Matcher for RoutePattern { + fn visit(&self, visitor: &mut MatcherVisitor) { + visitor.visit_match_regex(&self.regex); + } +} + +let routes = vec![ + RoutePattern { regex: r"^/items/\d+$".to_string() }, + RoutePattern { regex: r"^/users?/.*$".to_string() }, +]; + +let mut prefilter = RouterPrefilter::new(); +for (i, route) in routes.into_iter().enumerate() { + prefilter.insert(i, route); +} + +let matches: Vec<_> = prefilter.possible_matches("/items/100").collect(); +assert_eq!(matches, vec![&0]); +``` + +## How It Works + +The prefilter analyzes route matchers to extract literal prefixes and builds an efficient data structure for fast lookup. +The route matcher must implement the `Matcher` trait, and must call the specified functions on the passed visitor. +This can include nesting, multiple patterns `AND`ed and `OR`ed together, etc. + +If we are able to find a small set of prefixes where one _must_ match at the beginning of the actual string in order for the overall pattern to match, those found prefixes will be used as a prefilter. +e.g. `path startsWith "/abc" OR path matchesRegex "^/ef[gh]"`: the path must start with either `/abc`, `/efg` or `/efh`. +Alternations which cannot be proved to start with anything will cause prefiltering to be impossible for that route. +e.g. `path startsWith "/abc" OR path endsWith "zzz"`: prefiltering is impossible because the path could start with _anything_. + +[`router_prefilter`]: https://github.com/Dr-Emann/router-prefilter \ No newline at end of file diff --git a/crates/atc_router_prefilter/benches/github_paths.txt b/crates/atc_router_prefilter/benches/github_paths.txt new file mode 100644 index 00000000..daee3539 --- /dev/null +++ b/crates/atc_router_prefilter/benches/github_paths.txt @@ -0,0 +1,1078 @@ +get:/ +get:/advisories +get:/advisories/{ghsa_id} +get:/app +post:/app-manifests/{code}/conversions +get:/app/hook/config +patch:/app/hook/config +get:/app/hook/deliveries +get:/app/hook/deliveries/{delivery_id} +post:/app/hook/deliveries/{delivery_id}/attempts +get:/app/installation-requests +get:/app/installations +delete:/app/installations/{installation_id} +get:/app/installations/{installation_id} +post:/app/installations/{installation_id}/access_tokens +delete:/app/installations/{installation_id}/suspended +put:/app/installations/{installation_id}/suspended +delete:/applications/{client_id}/grant +delete:/applications/{client_id}/token +patch:/applications/{client_id}/token +post:/applications/{client_id}/token +post:/applications/{client_id}/token/scoped +get:/apps/{app_slug} +get:/assignments/{assignment_id} +get:/assignments/{assignment_id}/accepted_assignments +get:/assignments/{assignment_id}/grades +get:/classrooms +get:/classrooms/{classroom_id} +get:/classrooms/{classroom_id}/assignments +get:/codes_of_conduct +get:/codes_of_conduct/{key} +post:/credentials/revoke +get:/emojis +get:/enterprises/{enterprise}/actions/cache/retention-limit +put:/enterprises/{enterprise}/actions/cache/retention-limit +get:/enterprises/{enterprise}/actions/cache/storage-limit +put:/enterprises/{enterprise}/actions/cache/storage-limit +get:/enterprises/{enterprise}/code-security/configurations +post:/enterprises/{enterprise}/code-security/configurations +get:/enterprises/{enterprise}/code-security/configurations/defaults +delete:/enterprises/{enterprise}/code-security/configurations/{configuration_id} +get:/enterprises/{enterprise}/code-security/configurations/{configuration_id} +patch:/enterprises/{enterprise}/code-security/configurations/{configuration_id} +post:/enterprises/{enterprise}/code-security/configurations/{configuration_id}/attach +put:/enterprises/{enterprise}/code-security/configurations/{configuration_id}/defaults +get:/enterprises/{enterprise}/code-security/configurations/{configuration_id}/repositories +get:/enterprises/{enterprise}/dependabot/alerts +get:/enterprises/{enterprise}/teams +post:/enterprises/{enterprise}/teams +get:/enterprises/{enterprise}/teams/{enterprise-team}/memberships +post:/enterprises/{enterprise}/teams/{enterprise-team}/memberships/add +post:/enterprises/{enterprise}/teams/{enterprise-team}/memberships/remove +delete:/enterprises/{enterprise}/teams/{enterprise-team}/memberships/{username} +get:/enterprises/{enterprise}/teams/{enterprise-team}/memberships/{username} +put:/enterprises/{enterprise}/teams/{enterprise-team}/memberships/{username} +get:/enterprises/{enterprise}/teams/{enterprise-team}/organizations +post:/enterprises/{enterprise}/teams/{enterprise-team}/organizations/add +post:/enterprises/{enterprise}/teams/{enterprise-team}/organizations/remove +delete:/enterprises/{enterprise}/teams/{enterprise-team}/organizations/{org} +get:/enterprises/{enterprise}/teams/{enterprise-team}/organizations/{org} +put:/enterprises/{enterprise}/teams/{enterprise-team}/organizations/{org} +delete:/enterprises/{enterprise}/teams/{team_slug} +get:/enterprises/{enterprise}/teams/{team_slug} +patch:/enterprises/{enterprise}/teams/{team_slug} +get:/events +get:/feeds +get:/gists +post:/gists +get:/gists/public +get:/gists/starred +delete:/gists/{gist_id} +get:/gists/{gist_id} +patch:/gists/{gist_id} +get:/gists/{gist_id}/comments +post:/gists/{gist_id}/comments +delete:/gists/{gist_id}/comments/{comment_id} +get:/gists/{gist_id}/comments/{comment_id} +patch:/gists/{gist_id}/comments/{comment_id} +get:/gists/{gist_id}/commits +get:/gists/{gist_id}/forks +post:/gists/{gist_id}/forks +delete:/gists/{gist_id}/star +get:/gists/{gist_id}/star +put:/gists/{gist_id}/star +get:/gists/{gist_id}/{sha} +get:/gitignore/templates +get:/gitignore/templates/{name} +get:/installation/repositories +delete:/installation/token +get:/issues +get:/licenses +get:/licenses/{license} +post:/markdown +post:/markdown/raw +get:/marketplace_listing/accounts/{account_id} +get:/marketplace_listing/plans +get:/marketplace_listing/plans/{plan_id}/accounts +get:/marketplace_listing/stubbed/accounts/{account_id} +get:/marketplace_listing/stubbed/plans +get:/marketplace_listing/stubbed/plans/{plan_id}/accounts +get:/meta +get:/networks/{owner}/{repo}/events +get:/notifications +put:/notifications +delete:/notifications/threads/{thread_id} +get:/notifications/threads/{thread_id} +patch:/notifications/threads/{thread_id} +delete:/notifications/threads/{thread_id}/subscription +get:/notifications/threads/{thread_id}/subscription +put:/notifications/threads/{thread_id}/subscription +get:/octocat +get:/organizations +get:/organizations/{org}/actions/cache/retention-limit +put:/organizations/{org}/actions/cache/retention-limit +get:/organizations/{org}/actions/cache/storage-limit +put:/organizations/{org}/actions/cache/storage-limit +get:/organizations/{org}/dependabot/repository-access +patch:/organizations/{org}/dependabot/repository-access +put:/organizations/{org}/dependabot/repository-access/default-level +get:/organizations/{org}/settings/billing/budgets +delete:/organizations/{org}/settings/billing/budgets/{budget_id} +get:/organizations/{org}/settings/billing/budgets/{budget_id} +patch:/organizations/{org}/settings/billing/budgets/{budget_id} +get:/organizations/{org}/settings/billing/premium_request/usage +get:/organizations/{org}/settings/billing/usage +get:/organizations/{org}/settings/billing/usage/summary +delete:/orgs/{org} +get:/orgs/{org} +patch:/orgs/{org} +get:/orgs/{org}/actions/cache/usage +get:/orgs/{org}/actions/cache/usage-by-repository +get:/orgs/{org}/actions/hosted-runners +post:/orgs/{org}/actions/hosted-runners +get:/orgs/{org}/actions/hosted-runners/images/custom +delete:/orgs/{org}/actions/hosted-runners/images/custom/{image_definition_id} +get:/orgs/{org}/actions/hosted-runners/images/custom/{image_definition_id} +get:/orgs/{org}/actions/hosted-runners/images/custom/{image_definition_id}/versions +delete:/orgs/{org}/actions/hosted-runners/images/custom/{image_definition_id}/versions/{version} +get:/orgs/{org}/actions/hosted-runners/images/custom/{image_definition_id}/versions/{version} +get:/orgs/{org}/actions/hosted-runners/images/github-owned +get:/orgs/{org}/actions/hosted-runners/images/partner +get:/orgs/{org}/actions/hosted-runners/limits +get:/orgs/{org}/actions/hosted-runners/machine-sizes +get:/orgs/{org}/actions/hosted-runners/platforms +delete:/orgs/{org}/actions/hosted-runners/{hosted_runner_id} +get:/orgs/{org}/actions/hosted-runners/{hosted_runner_id} +patch:/orgs/{org}/actions/hosted-runners/{hosted_runner_id} +get:/orgs/{org}/actions/oidc/customization/sub +put:/orgs/{org}/actions/oidc/customization/sub +get:/orgs/{org}/actions/permissions +put:/orgs/{org}/actions/permissions +get:/orgs/{org}/actions/permissions/artifact-and-log-retention +put:/orgs/{org}/actions/permissions/artifact-and-log-retention +get:/orgs/{org}/actions/permissions/fork-pr-contributor-approval +put:/orgs/{org}/actions/permissions/fork-pr-contributor-approval +get:/orgs/{org}/actions/permissions/fork-pr-workflows-private-repos +put:/orgs/{org}/actions/permissions/fork-pr-workflows-private-repos +get:/orgs/{org}/actions/permissions/repositories +put:/orgs/{org}/actions/permissions/repositories +delete:/orgs/{org}/actions/permissions/repositories/{repository_id} +put:/orgs/{org}/actions/permissions/repositories/{repository_id} +get:/orgs/{org}/actions/permissions/selected-actions +put:/orgs/{org}/actions/permissions/selected-actions +get:/orgs/{org}/actions/permissions/self-hosted-runners +put:/orgs/{org}/actions/permissions/self-hosted-runners +get:/orgs/{org}/actions/permissions/self-hosted-runners/repositories +put:/orgs/{org}/actions/permissions/self-hosted-runners/repositories +delete:/orgs/{org}/actions/permissions/self-hosted-runners/repositories/{repository_id} +put:/orgs/{org}/actions/permissions/self-hosted-runners/repositories/{repository_id} +get:/orgs/{org}/actions/permissions/workflow +put:/orgs/{org}/actions/permissions/workflow +get:/orgs/{org}/actions/runner-groups +post:/orgs/{org}/actions/runner-groups +delete:/orgs/{org}/actions/runner-groups/{runner_group_id} +get:/orgs/{org}/actions/runner-groups/{runner_group_id} +patch:/orgs/{org}/actions/runner-groups/{runner_group_id} +get:/orgs/{org}/actions/runner-groups/{runner_group_id}/hosted-runners +get:/orgs/{org}/actions/runner-groups/{runner_group_id}/repositories +put:/orgs/{org}/actions/runner-groups/{runner_group_id}/repositories +delete:/orgs/{org}/actions/runner-groups/{runner_group_id}/repositories/{repository_id} +put:/orgs/{org}/actions/runner-groups/{runner_group_id}/repositories/{repository_id} +get:/orgs/{org}/actions/runner-groups/{runner_group_id}/runners +put:/orgs/{org}/actions/runner-groups/{runner_group_id}/runners +delete:/orgs/{org}/actions/runner-groups/{runner_group_id}/runners/{runner_id} +put:/orgs/{org}/actions/runner-groups/{runner_group_id}/runners/{runner_id} +get:/orgs/{org}/actions/runners +get:/orgs/{org}/actions/runners/downloads +post:/orgs/{org}/actions/runners/generate-jitconfig +post:/orgs/{org}/actions/runners/registration-token +post:/orgs/{org}/actions/runners/remove-token +delete:/orgs/{org}/actions/runners/{runner_id} +get:/orgs/{org}/actions/runners/{runner_id} +delete:/orgs/{org}/actions/runners/{runner_id}/labels +get:/orgs/{org}/actions/runners/{runner_id}/labels +post:/orgs/{org}/actions/runners/{runner_id}/labels +put:/orgs/{org}/actions/runners/{runner_id}/labels +delete:/orgs/{org}/actions/runners/{runner_id}/labels/{name} +get:/orgs/{org}/actions/secrets +get:/orgs/{org}/actions/secrets/public-key +delete:/orgs/{org}/actions/secrets/{secret_name} +get:/orgs/{org}/actions/secrets/{secret_name} +put:/orgs/{org}/actions/secrets/{secret_name} +get:/orgs/{org}/actions/secrets/{secret_name}/repositories +put:/orgs/{org}/actions/secrets/{secret_name}/repositories +delete:/orgs/{org}/actions/secrets/{secret_name}/repositories/{repository_id} +put:/orgs/{org}/actions/secrets/{secret_name}/repositories/{repository_id} +get:/orgs/{org}/actions/variables +post:/orgs/{org}/actions/variables +delete:/orgs/{org}/actions/variables/{name} +get:/orgs/{org}/actions/variables/{name} +patch:/orgs/{org}/actions/variables/{name} +get:/orgs/{org}/actions/variables/{name}/repositories +put:/orgs/{org}/actions/variables/{name}/repositories +delete:/orgs/{org}/actions/variables/{name}/repositories/{repository_id} +put:/orgs/{org}/actions/variables/{name}/repositories/{repository_id} +post:/orgs/{org}/artifacts/metadata/deployment-record +post:/orgs/{org}/artifacts/metadata/deployment-record/cluster/{cluster} +post:/orgs/{org}/artifacts/metadata/storage-record +get:/orgs/{org}/artifacts/{subject_digest}/metadata/deployment-records +get:/orgs/{org}/artifacts/{subject_digest}/metadata/storage-records +post:/orgs/{org}/attestations/bulk-list +post:/orgs/{org}/attestations/delete-request +delete:/orgs/{org}/attestations/digest/{subject_digest} +get:/orgs/{org}/attestations/repositories +delete:/orgs/{org}/attestations/{attestation_id} +get:/orgs/{org}/attestations/{subject_digest} +get:/orgs/{org}/blocks +delete:/orgs/{org}/blocks/{username} +get:/orgs/{org}/blocks/{username} +put:/orgs/{org}/blocks/{username} +get:/orgs/{org}/campaigns +post:/orgs/{org}/campaigns +delete:/orgs/{org}/campaigns/{campaign_number} +get:/orgs/{org}/campaigns/{campaign_number} +patch:/orgs/{org}/campaigns/{campaign_number} +get:/orgs/{org}/code-scanning/alerts +get:/orgs/{org}/code-security/configurations +post:/orgs/{org}/code-security/configurations +get:/orgs/{org}/code-security/configurations/defaults +delete:/orgs/{org}/code-security/configurations/detach +delete:/orgs/{org}/code-security/configurations/{configuration_id} +get:/orgs/{org}/code-security/configurations/{configuration_id} +patch:/orgs/{org}/code-security/configurations/{configuration_id} +post:/orgs/{org}/code-security/configurations/{configuration_id}/attach +put:/orgs/{org}/code-security/configurations/{configuration_id}/defaults +get:/orgs/{org}/code-security/configurations/{configuration_id}/repositories +get:/orgs/{org}/codespaces +put:/orgs/{org}/codespaces/access +delete:/orgs/{org}/codespaces/access/selected_users +post:/orgs/{org}/codespaces/access/selected_users +get:/orgs/{org}/codespaces/secrets +get:/orgs/{org}/codespaces/secrets/public-key +delete:/orgs/{org}/codespaces/secrets/{secret_name} +get:/orgs/{org}/codespaces/secrets/{secret_name} +put:/orgs/{org}/codespaces/secrets/{secret_name} +get:/orgs/{org}/codespaces/secrets/{secret_name}/repositories +put:/orgs/{org}/codespaces/secrets/{secret_name}/repositories +delete:/orgs/{org}/codespaces/secrets/{secret_name}/repositories/{repository_id} +put:/orgs/{org}/codespaces/secrets/{secret_name}/repositories/{repository_id} +get:/orgs/{org}/copilot/billing +get:/orgs/{org}/copilot/billing/seats +delete:/orgs/{org}/copilot/billing/selected_teams +post:/orgs/{org}/copilot/billing/selected_teams +delete:/orgs/{org}/copilot/billing/selected_users +post:/orgs/{org}/copilot/billing/selected_users +get:/orgs/{org}/copilot/metrics +get:/orgs/{org}/dependabot/alerts +get:/orgs/{org}/dependabot/secrets +get:/orgs/{org}/dependabot/secrets/public-key +delete:/orgs/{org}/dependabot/secrets/{secret_name} +get:/orgs/{org}/dependabot/secrets/{secret_name} +put:/orgs/{org}/dependabot/secrets/{secret_name} +get:/orgs/{org}/dependabot/secrets/{secret_name}/repositories +put:/orgs/{org}/dependabot/secrets/{secret_name}/repositories +delete:/orgs/{org}/dependabot/secrets/{secret_name}/repositories/{repository_id} +put:/orgs/{org}/dependabot/secrets/{secret_name}/repositories/{repository_id} +get:/orgs/{org}/docker/conflicts +get:/orgs/{org}/events +get:/orgs/{org}/failed_invitations +get:/orgs/{org}/hooks +post:/orgs/{org}/hooks +delete:/orgs/{org}/hooks/{hook_id} +get:/orgs/{org}/hooks/{hook_id} +patch:/orgs/{org}/hooks/{hook_id} +get:/orgs/{org}/hooks/{hook_id}/config +patch:/orgs/{org}/hooks/{hook_id}/config +get:/orgs/{org}/hooks/{hook_id}/deliveries +get:/orgs/{org}/hooks/{hook_id}/deliveries/{delivery_id} +post:/orgs/{org}/hooks/{hook_id}/deliveries/{delivery_id}/attempts +post:/orgs/{org}/hooks/{hook_id}/pings +get:/orgs/{org}/insights/api/route-stats/{actor_type}/{actor_id} +get:/orgs/{org}/insights/api/subject-stats +get:/orgs/{org}/insights/api/summary-stats +get:/orgs/{org}/insights/api/summary-stats/users/{user_id} +get:/orgs/{org}/insights/api/summary-stats/{actor_type}/{actor_id} +get:/orgs/{org}/insights/api/time-stats +get:/orgs/{org}/insights/api/time-stats/users/{user_id} +get:/orgs/{org}/insights/api/time-stats/{actor_type}/{actor_id} +get:/orgs/{org}/insights/api/user-stats/{user_id} +get:/orgs/{org}/installation +get:/orgs/{org}/installations +delete:/orgs/{org}/interaction-limits +get:/orgs/{org}/interaction-limits +put:/orgs/{org}/interaction-limits +get:/orgs/{org}/invitations +post:/orgs/{org}/invitations +delete:/orgs/{org}/invitations/{invitation_id} +get:/orgs/{org}/invitations/{invitation_id}/teams +get:/orgs/{org}/issue-types +post:/orgs/{org}/issue-types +delete:/orgs/{org}/issue-types/{issue_type_id} +put:/orgs/{org}/issue-types/{issue_type_id} +get:/orgs/{org}/issues +get:/orgs/{org}/members +delete:/orgs/{org}/members/{username} +get:/orgs/{org}/members/{username} +get:/orgs/{org}/members/{username}/codespaces +delete:/orgs/{org}/members/{username}/codespaces/{codespace_name} +post:/orgs/{org}/members/{username}/codespaces/{codespace_name}/stop +get:/orgs/{org}/members/{username}/copilot +delete:/orgs/{org}/memberships/{username} +get:/orgs/{org}/memberships/{username} +put:/orgs/{org}/memberships/{username} +get:/orgs/{org}/migrations +post:/orgs/{org}/migrations +get:/orgs/{org}/migrations/{migration_id} +delete:/orgs/{org}/migrations/{migration_id}/archive +get:/orgs/{org}/migrations/{migration_id}/archive +delete:/orgs/{org}/migrations/{migration_id}/repos/{repo_name}/lock +get:/orgs/{org}/migrations/{migration_id}/repositories +get:/orgs/{org}/organization-roles +delete:/orgs/{org}/organization-roles/teams/{team_slug} +delete:/orgs/{org}/organization-roles/teams/{team_slug}/{role_id} +put:/orgs/{org}/organization-roles/teams/{team_slug}/{role_id} +delete:/orgs/{org}/organization-roles/users/{username} +delete:/orgs/{org}/organization-roles/users/{username}/{role_id} +put:/orgs/{org}/organization-roles/users/{username}/{role_id} +get:/orgs/{org}/organization-roles/{role_id} +get:/orgs/{org}/organization-roles/{role_id}/teams +get:/orgs/{org}/organization-roles/{role_id}/users +get:/orgs/{org}/outside_collaborators +delete:/orgs/{org}/outside_collaborators/{username} +put:/orgs/{org}/outside_collaborators/{username} +get:/orgs/{org}/packages +delete:/orgs/{org}/packages/{package_type}/{package_name} +get:/orgs/{org}/packages/{package_type}/{package_name} +post:/orgs/{org}/packages/{package_type}/{package_name}/restore +get:/orgs/{org}/packages/{package_type}/{package_name}/versions +delete:/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id} +get:/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id} +post:/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}/restore +get:/orgs/{org}/personal-access-token-requests +post:/orgs/{org}/personal-access-token-requests +post:/orgs/{org}/personal-access-token-requests/{pat_request_id} +get:/orgs/{org}/personal-access-token-requests/{pat_request_id}/repositories +get:/orgs/{org}/personal-access-tokens +post:/orgs/{org}/personal-access-tokens +post:/orgs/{org}/personal-access-tokens/{pat_id} +get:/orgs/{org}/personal-access-tokens/{pat_id}/repositories +get:/orgs/{org}/private-registries +post:/orgs/{org}/private-registries +get:/orgs/{org}/private-registries/public-key +delete:/orgs/{org}/private-registries/{secret_name} +get:/orgs/{org}/private-registries/{secret_name} +patch:/orgs/{org}/private-registries/{secret_name} +get:/orgs/{org}/projectsV2 +get:/orgs/{org}/projectsV2/{project_number} +post:/orgs/{org}/projectsV2/{project_number}/drafts +get:/orgs/{org}/projectsV2/{project_number}/fields +post:/orgs/{org}/projectsV2/{project_number}/fields +get:/orgs/{org}/projectsV2/{project_number}/fields/{field_id} +get:/orgs/{org}/projectsV2/{project_number}/items +post:/orgs/{org}/projectsV2/{project_number}/items +delete:/orgs/{org}/projectsV2/{project_number}/items/{item_id} +get:/orgs/{org}/projectsV2/{project_number}/items/{item_id} +patch:/orgs/{org}/projectsV2/{project_number}/items/{item_id} +post:/orgs/{org}/projectsV2/{project_number}/views +get:/orgs/{org}/projectsV2/{project_number}/views/{view_number}/items +get:/orgs/{org}/properties/schema +patch:/orgs/{org}/properties/schema +delete:/orgs/{org}/properties/schema/{custom_property_name} +get:/orgs/{org}/properties/schema/{custom_property_name} +put:/orgs/{org}/properties/schema/{custom_property_name} +get:/orgs/{org}/properties/values +patch:/orgs/{org}/properties/values +get:/orgs/{org}/public_members +delete:/orgs/{org}/public_members/{username} +get:/orgs/{org}/public_members/{username} +put:/orgs/{org}/public_members/{username} +get:/orgs/{org}/repos +post:/orgs/{org}/repos +get:/orgs/{org}/rulesets +post:/orgs/{org}/rulesets +get:/orgs/{org}/rulesets/rule-suites +get:/orgs/{org}/rulesets/rule-suites/{rule_suite_id} +delete:/orgs/{org}/rulesets/{ruleset_id} +get:/orgs/{org}/rulesets/{ruleset_id} +put:/orgs/{org}/rulesets/{ruleset_id} +get:/orgs/{org}/rulesets/{ruleset_id}/history +get:/orgs/{org}/rulesets/{ruleset_id}/history/{version_id} +get:/orgs/{org}/secret-scanning/alerts +get:/orgs/{org}/secret-scanning/pattern-configurations +patch:/orgs/{org}/secret-scanning/pattern-configurations +get:/orgs/{org}/security-advisories +get:/orgs/{org}/security-managers +delete:/orgs/{org}/security-managers/teams/{team_slug} +put:/orgs/{org}/security-managers/teams/{team_slug} +get:/orgs/{org}/settings/immutable-releases +put:/orgs/{org}/settings/immutable-releases +get:/orgs/{org}/settings/immutable-releases/repositories +put:/orgs/{org}/settings/immutable-releases/repositories +delete:/orgs/{org}/settings/immutable-releases/repositories/{repository_id} +put:/orgs/{org}/settings/immutable-releases/repositories/{repository_id} +get:/orgs/{org}/settings/network-configurations +post:/orgs/{org}/settings/network-configurations +delete:/orgs/{org}/settings/network-configurations/{network_configuration_id} +get:/orgs/{org}/settings/network-configurations/{network_configuration_id} +patch:/orgs/{org}/settings/network-configurations/{network_configuration_id} +get:/orgs/{org}/settings/network-settings/{network_settings_id} +get:/orgs/{org}/team/{team_slug}/copilot/metrics +get:/orgs/{org}/teams +post:/orgs/{org}/teams +delete:/orgs/{org}/teams/{team_slug} +get:/orgs/{org}/teams/{team_slug} +patch:/orgs/{org}/teams/{team_slug} +get:/orgs/{org}/teams/{team_slug}/invitations +get:/orgs/{org}/teams/{team_slug}/members +delete:/orgs/{org}/teams/{team_slug}/memberships/{username} +get:/orgs/{org}/teams/{team_slug}/memberships/{username} +put:/orgs/{org}/teams/{team_slug}/memberships/{username} +get:/orgs/{org}/teams/{team_slug}/repos +delete:/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo} +get:/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo} +put:/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo} +get:/orgs/{org}/teams/{team_slug}/teams +post:/orgs/{org}/{security_product}/{enablement} +get:/rate_limit +delete:/repos/{owner}/{repo} +get:/repos/{owner}/{repo} +patch:/repos/{owner}/{repo} +get:/repos/{owner}/{repo}/actions/artifacts +delete:/repos/{owner}/{repo}/actions/artifacts/{artifact_id} +get:/repos/{owner}/{repo}/actions/artifacts/{artifact_id} +get:/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format} +get:/repos/{owner}/{repo}/actions/cache/retention-limit +put:/repos/{owner}/{repo}/actions/cache/retention-limit +get:/repos/{owner}/{repo}/actions/cache/storage-limit +put:/repos/{owner}/{repo}/actions/cache/storage-limit +get:/repos/{owner}/{repo}/actions/cache/usage +delete:/repos/{owner}/{repo}/actions/caches +get:/repos/{owner}/{repo}/actions/caches +delete:/repos/{owner}/{repo}/actions/caches/{cache_id} +get:/repos/{owner}/{repo}/actions/jobs/{job_id} +get:/repos/{owner}/{repo}/actions/jobs/{job_id}/logs +post:/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun +get:/repos/{owner}/{repo}/actions/oidc/customization/sub +put:/repos/{owner}/{repo}/actions/oidc/customization/sub +get:/repos/{owner}/{repo}/actions/organization-secrets +get:/repos/{owner}/{repo}/actions/organization-variables +get:/repos/{owner}/{repo}/actions/permissions +put:/repos/{owner}/{repo}/actions/permissions +get:/repos/{owner}/{repo}/actions/permissions/access +put:/repos/{owner}/{repo}/actions/permissions/access +get:/repos/{owner}/{repo}/actions/permissions/artifact-and-log-retention +put:/repos/{owner}/{repo}/actions/permissions/artifact-and-log-retention +get:/repos/{owner}/{repo}/actions/permissions/fork-pr-contributor-approval +put:/repos/{owner}/{repo}/actions/permissions/fork-pr-contributor-approval +get:/repos/{owner}/{repo}/actions/permissions/fork-pr-workflows-private-repos +put:/repos/{owner}/{repo}/actions/permissions/fork-pr-workflows-private-repos +get:/repos/{owner}/{repo}/actions/permissions/selected-actions +put:/repos/{owner}/{repo}/actions/permissions/selected-actions +get:/repos/{owner}/{repo}/actions/permissions/workflow +put:/repos/{owner}/{repo}/actions/permissions/workflow +get:/repos/{owner}/{repo}/actions/runners +get:/repos/{owner}/{repo}/actions/runners/downloads +post:/repos/{owner}/{repo}/actions/runners/generate-jitconfig +post:/repos/{owner}/{repo}/actions/runners/registration-token +post:/repos/{owner}/{repo}/actions/runners/remove-token +delete:/repos/{owner}/{repo}/actions/runners/{runner_id} +get:/repos/{owner}/{repo}/actions/runners/{runner_id} +delete:/repos/{owner}/{repo}/actions/runners/{runner_id}/labels +get:/repos/{owner}/{repo}/actions/runners/{runner_id}/labels +post:/repos/{owner}/{repo}/actions/runners/{runner_id}/labels +put:/repos/{owner}/{repo}/actions/runners/{runner_id}/labels +delete:/repos/{owner}/{repo}/actions/runners/{runner_id}/labels/{name} +get:/repos/{owner}/{repo}/actions/runs +delete:/repos/{owner}/{repo}/actions/runs/{run_id} +get:/repos/{owner}/{repo}/actions/runs/{run_id} +get:/repos/{owner}/{repo}/actions/runs/{run_id}/approvals +post:/repos/{owner}/{repo}/actions/runs/{run_id}/approve +get:/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts +get:/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number} +get:/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/jobs +get:/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/logs +post:/repos/{owner}/{repo}/actions/runs/{run_id}/cancel +post:/repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule +post:/repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel +get:/repos/{owner}/{repo}/actions/runs/{run_id}/jobs +delete:/repos/{owner}/{repo}/actions/runs/{run_id}/logs +get:/repos/{owner}/{repo}/actions/runs/{run_id}/logs +get:/repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments +post:/repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments +post:/repos/{owner}/{repo}/actions/runs/{run_id}/rerun +post:/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs +get:/repos/{owner}/{repo}/actions/runs/{run_id}/timing +get:/repos/{owner}/{repo}/actions/secrets +get:/repos/{owner}/{repo}/actions/secrets/public-key +delete:/repos/{owner}/{repo}/actions/secrets/{secret_name} +get:/repos/{owner}/{repo}/actions/secrets/{secret_name} +put:/repos/{owner}/{repo}/actions/secrets/{secret_name} +get:/repos/{owner}/{repo}/actions/variables +post:/repos/{owner}/{repo}/actions/variables +delete:/repos/{owner}/{repo}/actions/variables/{name} +get:/repos/{owner}/{repo}/actions/variables/{name} +patch:/repos/{owner}/{repo}/actions/variables/{name} +get:/repos/{owner}/{repo}/actions/workflows +get:/repos/{owner}/{repo}/actions/workflows/{workflow_id} +put:/repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable +post:/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches +put:/repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable +get:/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs +get:/repos/{owner}/{repo}/actions/workflows/{workflow_id}/timing +get:/repos/{owner}/{repo}/activity +get:/repos/{owner}/{repo}/assignees +get:/repos/{owner}/{repo}/assignees/{assignee} +post:/repos/{owner}/{repo}/attestations +get:/repos/{owner}/{repo}/attestations/{subject_digest} +get:/repos/{owner}/{repo}/autolinks +post:/repos/{owner}/{repo}/autolinks +delete:/repos/{owner}/{repo}/autolinks/{autolink_id} +get:/repos/{owner}/{repo}/autolinks/{autolink_id} +delete:/repos/{owner}/{repo}/automated-security-fixes +get:/repos/{owner}/{repo}/automated-security-fixes +put:/repos/{owner}/{repo}/automated-security-fixes +get:/repos/{owner}/{repo}/branches +get:/repos/{owner}/{repo}/branches/{branch} +delete:/repos/{owner}/{repo}/branches/{branch}/protection +get:/repos/{owner}/{repo}/branches/{branch}/protection +put:/repos/{owner}/{repo}/branches/{branch}/protection +delete:/repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins +get:/repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins +post:/repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins +delete:/repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews +get:/repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews +patch:/repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews +delete:/repos/{owner}/{repo}/branches/{branch}/protection/required_signatures +get:/repos/{owner}/{repo}/branches/{branch}/protection/required_signatures +post:/repos/{owner}/{repo}/branches/{branch}/protection/required_signatures +delete:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks +get:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks +patch:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks +delete:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks/contexts +get:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks/contexts +post:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks/contexts +put:/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks/contexts +delete:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions +get:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions +delete:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/apps +get:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/apps +post:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/apps +put:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/apps +delete:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/teams +get:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/teams +post:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/teams +put:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/teams +delete:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/users +get:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/users +post:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/users +put:/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/users +post:/repos/{owner}/{repo}/branches/{branch}/rename +post:/repos/{owner}/{repo}/check-runs +get:/repos/{owner}/{repo}/check-runs/{check_run_id} +patch:/repos/{owner}/{repo}/check-runs/{check_run_id} +get:/repos/{owner}/{repo}/check-runs/{check_run_id}/annotations +post:/repos/{owner}/{repo}/check-runs/{check_run_id}/rerequest +post:/repos/{owner}/{repo}/check-suites +patch:/repos/{owner}/{repo}/check-suites/preferences +get:/repos/{owner}/{repo}/check-suites/{check_suite_id} +get:/repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs +post:/repos/{owner}/{repo}/check-suites/{check_suite_id}/rerequest +get:/repos/{owner}/{repo}/code-scanning/alerts +get:/repos/{owner}/{repo}/code-scanning/alerts/{alert_number} +patch:/repos/{owner}/{repo}/code-scanning/alerts/{alert_number} +get:/repos/{owner}/{repo}/code-scanning/alerts/{alert_number}/autofix +post:/repos/{owner}/{repo}/code-scanning/alerts/{alert_number}/autofix +post:/repos/{owner}/{repo}/code-scanning/alerts/{alert_number}/autofix/commits +get:/repos/{owner}/{repo}/code-scanning/alerts/{alert_number}/instances +get:/repos/{owner}/{repo}/code-scanning/analyses +delete:/repos/{owner}/{repo}/code-scanning/analyses/{analysis_id} +get:/repos/{owner}/{repo}/code-scanning/analyses/{analysis_id} +get:/repos/{owner}/{repo}/code-scanning/codeql/databases +delete:/repos/{owner}/{repo}/code-scanning/codeql/databases/{language} +get:/repos/{owner}/{repo}/code-scanning/codeql/databases/{language} +post:/repos/{owner}/{repo}/code-scanning/codeql/variant-analyses +get:/repos/{owner}/{repo}/code-scanning/codeql/variant-analyses/{codeql_variant_analysis_id} +get:/repos/{owner}/{repo}/code-scanning/codeql/variant-analyses/{codeql_variant_analysis_id}/repos/{repo_owner}/{repo_name} +get:/repos/{owner}/{repo}/code-scanning/default-setup +patch:/repos/{owner}/{repo}/code-scanning/default-setup +post:/repos/{owner}/{repo}/code-scanning/sarifs +get:/repos/{owner}/{repo}/code-scanning/sarifs/{sarif_id} +get:/repos/{owner}/{repo}/code-security-configuration +get:/repos/{owner}/{repo}/codeowners/errors +get:/repos/{owner}/{repo}/codespaces +post:/repos/{owner}/{repo}/codespaces +get:/repos/{owner}/{repo}/codespaces/devcontainers +get:/repos/{owner}/{repo}/codespaces/machines +get:/repos/{owner}/{repo}/codespaces/new +get:/repos/{owner}/{repo}/codespaces/permissions_check +get:/repos/{owner}/{repo}/codespaces/secrets +get:/repos/{owner}/{repo}/codespaces/secrets/public-key +delete:/repos/{owner}/{repo}/codespaces/secrets/{secret_name} +get:/repos/{owner}/{repo}/codespaces/secrets/{secret_name} +put:/repos/{owner}/{repo}/codespaces/secrets/{secret_name} +get:/repos/{owner}/{repo}/collaborators +delete:/repos/{owner}/{repo}/collaborators/{username} +get:/repos/{owner}/{repo}/collaborators/{username} +put:/repos/{owner}/{repo}/collaborators/{username} +get:/repos/{owner}/{repo}/collaborators/{username}/permission +get:/repos/{owner}/{repo}/comments +delete:/repos/{owner}/{repo}/comments/{comment_id} +get:/repos/{owner}/{repo}/comments/{comment_id} +patch:/repos/{owner}/{repo}/comments/{comment_id} +get:/repos/{owner}/{repo}/comments/{comment_id}/reactions +post:/repos/{owner}/{repo}/comments/{comment_id}/reactions +delete:/repos/{owner}/{repo}/comments/{comment_id}/reactions/{reaction_id} +get:/repos/{owner}/{repo}/commits +get:/repos/{owner}/{repo}/commits/{commit_sha}/branches-where-head +get:/repos/{owner}/{repo}/commits/{commit_sha}/comments +post:/repos/{owner}/{repo}/commits/{commit_sha}/comments +get:/repos/{owner}/{repo}/commits/{commit_sha}/pulls +get:/repos/{owner}/{repo}/commits/{ref} +get:/repos/{owner}/{repo}/commits/{ref}/check-runs +get:/repos/{owner}/{repo}/commits/{ref}/check-suites +get:/repos/{owner}/{repo}/commits/{ref}/status +get:/repos/{owner}/{repo}/commits/{ref}/statuses +get:/repos/{owner}/{repo}/community/profile +get:/repos/{owner}/{repo}/compare/{basehead} +delete:/repos/{owner}/{repo}/contents/{path} +get:/repos/{owner}/{repo}/contents/{path} +put:/repos/{owner}/{repo}/contents/{path} +get:/repos/{owner}/{repo}/contributors +get:/repos/{owner}/{repo}/dependabot/alerts +get:/repos/{owner}/{repo}/dependabot/alerts/{alert_number} +patch:/repos/{owner}/{repo}/dependabot/alerts/{alert_number} +get:/repos/{owner}/{repo}/dependabot/secrets +get:/repos/{owner}/{repo}/dependabot/secrets/public-key +delete:/repos/{owner}/{repo}/dependabot/secrets/{secret_name} +get:/repos/{owner}/{repo}/dependabot/secrets/{secret_name} +put:/repos/{owner}/{repo}/dependabot/secrets/{secret_name} +get:/repos/{owner}/{repo}/dependency-graph/compare/{basehead} +get:/repos/{owner}/{repo}/dependency-graph/sbom +post:/repos/{owner}/{repo}/dependency-graph/snapshots +get:/repos/{owner}/{repo}/deployments +post:/repos/{owner}/{repo}/deployments +delete:/repos/{owner}/{repo}/deployments/{deployment_id} +get:/repos/{owner}/{repo}/deployments/{deployment_id} +get:/repos/{owner}/{repo}/deployments/{deployment_id}/statuses +post:/repos/{owner}/{repo}/deployments/{deployment_id}/statuses +get:/repos/{owner}/{repo}/deployments/{deployment_id}/statuses/{status_id} +post:/repos/{owner}/{repo}/dispatches +get:/repos/{owner}/{repo}/environments +delete:/repos/{owner}/{repo}/environments/{environment_name} +get:/repos/{owner}/{repo}/environments/{environment_name} +put:/repos/{owner}/{repo}/environments/{environment_name} +get:/repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies +post:/repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies +delete:/repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies/{branch_policy_id} +get:/repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies/{branch_policy_id} +put:/repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies/{branch_policy_id} +get:/repos/{owner}/{repo}/environments/{environment_name}/deployment_protection_rules +post:/repos/{owner}/{repo}/environments/{environment_name}/deployment_protection_rules +get:/repos/{owner}/{repo}/environments/{environment_name}/deployment_protection_rules/apps +delete:/repos/{owner}/{repo}/environments/{environment_name}/deployment_protection_rules/{protection_rule_id} +get:/repos/{owner}/{repo}/environments/{environment_name}/deployment_protection_rules/{protection_rule_id} +get:/repos/{owner}/{repo}/environments/{environment_name}/secrets +get:/repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key +delete:/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name} +get:/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name} +put:/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name} +get:/repos/{owner}/{repo}/environments/{environment_name}/variables +post:/repos/{owner}/{repo}/environments/{environment_name}/variables +delete:/repos/{owner}/{repo}/environments/{environment_name}/variables/{name} +get:/repos/{owner}/{repo}/environments/{environment_name}/variables/{name} +patch:/repos/{owner}/{repo}/environments/{environment_name}/variables/{name} +get:/repos/{owner}/{repo}/events +get:/repos/{owner}/{repo}/forks +post:/repos/{owner}/{repo}/forks +post:/repos/{owner}/{repo}/git/blobs +get:/repos/{owner}/{repo}/git/blobs/{file_sha} +post:/repos/{owner}/{repo}/git/commits +get:/repos/{owner}/{repo}/git/commits/{commit_sha} +get:/repos/{owner}/{repo}/git/matching-refs/{ref} +get:/repos/{owner}/{repo}/git/ref/{ref} +post:/repos/{owner}/{repo}/git/refs +delete:/repos/{owner}/{repo}/git/refs/{ref} +patch:/repos/{owner}/{repo}/git/refs/{ref} +post:/repos/{owner}/{repo}/git/tags +get:/repos/{owner}/{repo}/git/tags/{tag_sha} +post:/repos/{owner}/{repo}/git/trees +get:/repos/{owner}/{repo}/git/trees/{tree_sha} +get:/repos/{owner}/{repo}/hooks +post:/repos/{owner}/{repo}/hooks +delete:/repos/{owner}/{repo}/hooks/{hook_id} +get:/repos/{owner}/{repo}/hooks/{hook_id} +patch:/repos/{owner}/{repo}/hooks/{hook_id} +get:/repos/{owner}/{repo}/hooks/{hook_id}/config +patch:/repos/{owner}/{repo}/hooks/{hook_id}/config +get:/repos/{owner}/{repo}/hooks/{hook_id}/deliveries +get:/repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id} +post:/repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id}/attempts +post:/repos/{owner}/{repo}/hooks/{hook_id}/pings +post:/repos/{owner}/{repo}/hooks/{hook_id}/tests +delete:/repos/{owner}/{repo}/immutable-releases +get:/repos/{owner}/{repo}/immutable-releases +put:/repos/{owner}/{repo}/immutable-releases +delete:/repos/{owner}/{repo}/import +get:/repos/{owner}/{repo}/import +patch:/repos/{owner}/{repo}/import +put:/repos/{owner}/{repo}/import +get:/repos/{owner}/{repo}/import/authors +patch:/repos/{owner}/{repo}/import/authors/{author_id} +get:/repos/{owner}/{repo}/import/large_files +patch:/repos/{owner}/{repo}/import/lfs +get:/repos/{owner}/{repo}/installation +delete:/repos/{owner}/{repo}/interaction-limits +get:/repos/{owner}/{repo}/interaction-limits +put:/repos/{owner}/{repo}/interaction-limits +get:/repos/{owner}/{repo}/invitations +delete:/repos/{owner}/{repo}/invitations/{invitation_id} +patch:/repos/{owner}/{repo}/invitations/{invitation_id} +get:/repos/{owner}/{repo}/issues +post:/repos/{owner}/{repo}/issues +get:/repos/{owner}/{repo}/issues/comments +delete:/repos/{owner}/{repo}/issues/comments/{comment_id} +get:/repos/{owner}/{repo}/issues/comments/{comment_id} +patch:/repos/{owner}/{repo}/issues/comments/{comment_id} +get:/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions +post:/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions +delete:/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id} +get:/repos/{owner}/{repo}/issues/events +get:/repos/{owner}/{repo}/issues/events/{event_id} +get:/repos/{owner}/{repo}/issues/{issue_number} +patch:/repos/{owner}/{repo}/issues/{issue_number} +delete:/repos/{owner}/{repo}/issues/{issue_number}/assignees +post:/repos/{owner}/{repo}/issues/{issue_number}/assignees +get:/repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee} +get:/repos/{owner}/{repo}/issues/{issue_number}/comments +post:/repos/{owner}/{repo}/issues/{issue_number}/comments +get:/repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +post:/repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +delete:/repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id} +get:/repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocking +get:/repos/{owner}/{repo}/issues/{issue_number}/events +delete:/repos/{owner}/{repo}/issues/{issue_number}/labels +get:/repos/{owner}/{repo}/issues/{issue_number}/labels +post:/repos/{owner}/{repo}/issues/{issue_number}/labels +put:/repos/{owner}/{repo}/issues/{issue_number}/labels +delete:/repos/{owner}/{repo}/issues/{issue_number}/labels/{name} +delete:/repos/{owner}/{repo}/issues/{issue_number}/lock +put:/repos/{owner}/{repo}/issues/{issue_number}/lock +get:/repos/{owner}/{repo}/issues/{issue_number}/parent +get:/repos/{owner}/{repo}/issues/{issue_number}/reactions +post:/repos/{owner}/{repo}/issues/{issue_number}/reactions +delete:/repos/{owner}/{repo}/issues/{issue_number}/reactions/{reaction_id} +delete:/repos/{owner}/{repo}/issues/{issue_number}/sub_issue +get:/repos/{owner}/{repo}/issues/{issue_number}/sub_issues +post:/repos/{owner}/{repo}/issues/{issue_number}/sub_issues +patch:/repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority +get:/repos/{owner}/{repo}/issues/{issue_number}/timeline +get:/repos/{owner}/{repo}/keys +post:/repos/{owner}/{repo}/keys +delete:/repos/{owner}/{repo}/keys/{key_id} +get:/repos/{owner}/{repo}/keys/{key_id} +get:/repos/{owner}/{repo}/labels +post:/repos/{owner}/{repo}/labels +delete:/repos/{owner}/{repo}/labels/{name} +get:/repos/{owner}/{repo}/labels/{name} +patch:/repos/{owner}/{repo}/labels/{name} +get:/repos/{owner}/{repo}/languages +get:/repos/{owner}/{repo}/license +post:/repos/{owner}/{repo}/merge-upstream +post:/repos/{owner}/{repo}/merges +get:/repos/{owner}/{repo}/milestones +post:/repos/{owner}/{repo}/milestones +delete:/repos/{owner}/{repo}/milestones/{milestone_number} +get:/repos/{owner}/{repo}/milestones/{milestone_number} +patch:/repos/{owner}/{repo}/milestones/{milestone_number} +get:/repos/{owner}/{repo}/milestones/{milestone_number}/labels +get:/repos/{owner}/{repo}/notifications +put:/repos/{owner}/{repo}/notifications +delete:/repos/{owner}/{repo}/pages +get:/repos/{owner}/{repo}/pages +post:/repos/{owner}/{repo}/pages +put:/repos/{owner}/{repo}/pages +get:/repos/{owner}/{repo}/pages/builds +post:/repos/{owner}/{repo}/pages/builds +get:/repos/{owner}/{repo}/pages/builds/latest +get:/repos/{owner}/{repo}/pages/builds/{build_id} +post:/repos/{owner}/{repo}/pages/deployments +get:/repos/{owner}/{repo}/pages/deployments/{pages_deployment_id} +post:/repos/{owner}/{repo}/pages/deployments/{pages_deployment_id}/cancel +get:/repos/{owner}/{repo}/pages/health +delete:/repos/{owner}/{repo}/private-vulnerability-reporting +get:/repos/{owner}/{repo}/private-vulnerability-reporting +put:/repos/{owner}/{repo}/private-vulnerability-reporting +get:/repos/{owner}/{repo}/properties/values +patch:/repos/{owner}/{repo}/properties/values +get:/repos/{owner}/{repo}/pulls +post:/repos/{owner}/{repo}/pulls +get:/repos/{owner}/{repo}/pulls/comments +delete:/repos/{owner}/{repo}/pulls/comments/{comment_id} +get:/repos/{owner}/{repo}/pulls/comments/{comment_id} +patch:/repos/{owner}/{repo}/pulls/comments/{comment_id} +get:/repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions +post:/repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions +delete:/repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions/{reaction_id} +get:/repos/{owner}/{repo}/pulls/{pull_number} +patch:/repos/{owner}/{repo}/pulls/{pull_number} +post:/repos/{owner}/{repo}/pulls/{pull_number}/codespaces +get:/repos/{owner}/{repo}/pulls/{pull_number}/comments +post:/repos/{owner}/{repo}/pulls/{pull_number}/comments +post:/repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies +get:/repos/{owner}/{repo}/pulls/{pull_number}/commits +get:/repos/{owner}/{repo}/pulls/{pull_number}/files +get:/repos/{owner}/{repo}/pulls/{pull_number}/merge +put:/repos/{owner}/{repo}/pulls/{pull_number}/merge +delete:/repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers +get:/repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers +post:/repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers +get:/repos/{owner}/{repo}/pulls/{pull_number}/reviews +post:/repos/{owner}/{repo}/pulls/{pull_number}/reviews +delete:/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id} +get:/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id} +put:/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id} +get:/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments +put:/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals +post:/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events +put:/repos/{owner}/{repo}/pulls/{pull_number}/update-branch +get:/repos/{owner}/{repo}/readme +get:/repos/{owner}/{repo}/readme/{dir} +get:/repos/{owner}/{repo}/releases +post:/repos/{owner}/{repo}/releases +delete:/repos/{owner}/{repo}/releases/assets/{asset_id} +get:/repos/{owner}/{repo}/releases/assets/{asset_id} +patch:/repos/{owner}/{repo}/releases/assets/{asset_id} +post:/repos/{owner}/{repo}/releases/generate-notes +get:/repos/{owner}/{repo}/releases/latest +get:/repos/{owner}/{repo}/releases/tags/{tag} +delete:/repos/{owner}/{repo}/releases/{release_id} +get:/repos/{owner}/{repo}/releases/{release_id} +patch:/repos/{owner}/{repo}/releases/{release_id} +get:/repos/{owner}/{repo}/releases/{release_id}/assets +post:/repos/{owner}/{repo}/releases/{release_id}/assets +get:/repos/{owner}/{repo}/releases/{release_id}/reactions +post:/repos/{owner}/{repo}/releases/{release_id}/reactions +delete:/repos/{owner}/{repo}/releases/{release_id}/reactions/{reaction_id} +get:/repos/{owner}/{repo}/rules/branches/{branch} +get:/repos/{owner}/{repo}/rulesets +post:/repos/{owner}/{repo}/rulesets +get:/repos/{owner}/{repo}/rulesets/rule-suites +get:/repos/{owner}/{repo}/rulesets/rule-suites/{rule_suite_id} +delete:/repos/{owner}/{repo}/rulesets/{ruleset_id} +get:/repos/{owner}/{repo}/rulesets/{ruleset_id} +put:/repos/{owner}/{repo}/rulesets/{ruleset_id} +get:/repos/{owner}/{repo}/rulesets/{ruleset_id}/history +get:/repos/{owner}/{repo}/rulesets/{ruleset_id}/history/{version_id} +get:/repos/{owner}/{repo}/secret-scanning/alerts +get:/repos/{owner}/{repo}/secret-scanning/alerts/{alert_number} +patch:/repos/{owner}/{repo}/secret-scanning/alerts/{alert_number} +get:/repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}/locations +post:/repos/{owner}/{repo}/secret-scanning/push-protection-bypasses +get:/repos/{owner}/{repo}/secret-scanning/scan-history +get:/repos/{owner}/{repo}/security-advisories +post:/repos/{owner}/{repo}/security-advisories +post:/repos/{owner}/{repo}/security-advisories/reports +get:/repos/{owner}/{repo}/security-advisories/{ghsa_id} +patch:/repos/{owner}/{repo}/security-advisories/{ghsa_id} +post:/repos/{owner}/{repo}/security-advisories/{ghsa_id}/cve +post:/repos/{owner}/{repo}/security-advisories/{ghsa_id}/forks +get:/repos/{owner}/{repo}/stargazers +get:/repos/{owner}/{repo}/stats/code_frequency +get:/repos/{owner}/{repo}/stats/commit_activity +get:/repos/{owner}/{repo}/stats/contributors +get:/repos/{owner}/{repo}/stats/participation +get:/repos/{owner}/{repo}/stats/punch_card +post:/repos/{owner}/{repo}/statuses/{sha} +get:/repos/{owner}/{repo}/subscribers +delete:/repos/{owner}/{repo}/subscription +get:/repos/{owner}/{repo}/subscription +put:/repos/{owner}/{repo}/subscription +get:/repos/{owner}/{repo}/tags +get:/repos/{owner}/{repo}/tags/protection +post:/repos/{owner}/{repo}/tags/protection +delete:/repos/{owner}/{repo}/tags/protection/{tag_protection_id} +get:/repos/{owner}/{repo}/tarball/{ref} +get:/repos/{owner}/{repo}/teams +get:/repos/{owner}/{repo}/topics +put:/repos/{owner}/{repo}/topics +get:/repos/{owner}/{repo}/traffic/clones +get:/repos/{owner}/{repo}/traffic/popular/paths +get:/repos/{owner}/{repo}/traffic/popular/referrers +get:/repos/{owner}/{repo}/traffic/views +post:/repos/{owner}/{repo}/transfer +delete:/repos/{owner}/{repo}/vulnerability-alerts +get:/repos/{owner}/{repo}/vulnerability-alerts +put:/repos/{owner}/{repo}/vulnerability-alerts +get:/repos/{owner}/{repo}/zipball/{ref} +post:/repos/{template_owner}/{template_repo}/generate +get:/repositories +get:/search/code +get:/search/commits +get:/search/issues +get:/search/labels +get:/search/repositories +get:/search/topics +get:/search/users +delete:/teams/{team_id} +get:/teams/{team_id} +patch:/teams/{team_id} +get:/teams/{team_id}/invitations +get:/teams/{team_id}/members +delete:/teams/{team_id}/members/{username} +get:/teams/{team_id}/members/{username} +put:/teams/{team_id}/members/{username} +delete:/teams/{team_id}/memberships/{username} +get:/teams/{team_id}/memberships/{username} +put:/teams/{team_id}/memberships/{username} +get:/teams/{team_id}/repos +delete:/teams/{team_id}/repos/{owner}/{repo} +get:/teams/{team_id}/repos/{owner}/{repo} +put:/teams/{team_id}/repos/{owner}/{repo} +get:/teams/{team_id}/teams +get:/user +patch:/user +get:/user/blocks +delete:/user/blocks/{username} +get:/user/blocks/{username} +put:/user/blocks/{username} +get:/user/codespaces +post:/user/codespaces +get:/user/codespaces/secrets +get:/user/codespaces/secrets/public-key +delete:/user/codespaces/secrets/{secret_name} +get:/user/codespaces/secrets/{secret_name} +put:/user/codespaces/secrets/{secret_name} +get:/user/codespaces/secrets/{secret_name}/repositories +put:/user/codespaces/secrets/{secret_name}/repositories +delete:/user/codespaces/secrets/{secret_name}/repositories/{repository_id} +put:/user/codespaces/secrets/{secret_name}/repositories/{repository_id} +delete:/user/codespaces/{codespace_name} +get:/user/codespaces/{codespace_name} +patch:/user/codespaces/{codespace_name} +post:/user/codespaces/{codespace_name}/exports +get:/user/codespaces/{codespace_name}/exports/{export_id} +get:/user/codespaces/{codespace_name}/machines +post:/user/codespaces/{codespace_name}/publish +post:/user/codespaces/{codespace_name}/start +post:/user/codespaces/{codespace_name}/stop +get:/user/docker/conflicts +patch:/user/email/visibility +delete:/user/emails +get:/user/emails +post:/user/emails +get:/user/followers +get:/user/following +delete:/user/following/{username} +get:/user/following/{username} +put:/user/following/{username} +get:/user/gpg_keys +post:/user/gpg_keys +delete:/user/gpg_keys/{gpg_key_id} +get:/user/gpg_keys/{gpg_key_id} +get:/user/installations +get:/user/installations/{installation_id}/repositories +delete:/user/installations/{installation_id}/repositories/{repository_id} +put:/user/installations/{installation_id}/repositories/{repository_id} +delete:/user/interaction-limits +get:/user/interaction-limits +put:/user/interaction-limits +get:/user/issues +get:/user/keys +post:/user/keys +delete:/user/keys/{key_id} +get:/user/keys/{key_id} +get:/user/marketplace_purchases +get:/user/marketplace_purchases/stubbed +get:/user/memberships/orgs +get:/user/memberships/orgs/{org} +patch:/user/memberships/orgs/{org} +get:/user/migrations +post:/user/migrations +get:/user/migrations/{migration_id} +delete:/user/migrations/{migration_id}/archive +get:/user/migrations/{migration_id}/archive +delete:/user/migrations/{migration_id}/repos/{repo_name}/lock +get:/user/migrations/{migration_id}/repositories +get:/user/orgs +get:/user/packages +delete:/user/packages/{package_type}/{package_name} +get:/user/packages/{package_type}/{package_name} +post:/user/packages/{package_type}/{package_name}/restore +get:/user/packages/{package_type}/{package_name}/versions +delete:/user/packages/{package_type}/{package_name}/versions/{package_version_id} +get:/user/packages/{package_type}/{package_name}/versions/{package_version_id} +post:/user/packages/{package_type}/{package_name}/versions/{package_version_id}/restore +get:/user/public_emails +get:/user/repos +post:/user/repos +get:/user/repository_invitations +delete:/user/repository_invitations/{invitation_id} +patch:/user/repository_invitations/{invitation_id} +delete:/user/social_accounts +get:/user/social_accounts +post:/user/social_accounts +get:/user/ssh_signing_keys +post:/user/ssh_signing_keys +delete:/user/ssh_signing_keys/{ssh_signing_key_id} +get:/user/ssh_signing_keys/{ssh_signing_key_id} +get:/user/starred +delete:/user/starred/{owner}/{repo} +get:/user/starred/{owner}/{repo} +put:/user/starred/{owner}/{repo} +get:/user/subscriptions +get:/user/teams +get:/user/{account_id} +post:/user/{user_id}/projectsV2/{project_number}/drafts +get:/users +post:/users/{user_id}/projectsV2/{project_number}/views +get:/users/{username} +post:/users/{username}/attestations/bulk-list +post:/users/{username}/attestations/delete-request +delete:/users/{username}/attestations/digest/{subject_digest} +delete:/users/{username}/attestations/{attestation_id} +get:/users/{username}/attestations/{subject_digest} +get:/users/{username}/docker/conflicts +get:/users/{username}/events +get:/users/{username}/events/orgs/{org} +get:/users/{username}/events/public +get:/users/{username}/followers +get:/users/{username}/following +get:/users/{username}/following/{target_user} +get:/users/{username}/gists +get:/users/{username}/gpg_keys +get:/users/{username}/hovercard +get:/users/{username}/installation +get:/users/{username}/keys +get:/users/{username}/orgs +get:/users/{username}/packages +delete:/users/{username}/packages/{package_type}/{package_name} +get:/users/{username}/packages/{package_type}/{package_name} +post:/users/{username}/packages/{package_type}/{package_name}/restore +get:/users/{username}/packages/{package_type}/{package_name}/versions +delete:/users/{username}/packages/{package_type}/{package_name}/versions/{package_version_id} +get:/users/{username}/packages/{package_type}/{package_name}/versions/{package_version_id} +post:/users/{username}/packages/{package_type}/{package_name}/versions/{package_version_id}/restore +get:/users/{username}/projectsV2 +get:/users/{username}/projectsV2/{project_number} +get:/users/{username}/projectsV2/{project_number}/fields +post:/users/{username}/projectsV2/{project_number}/fields +get:/users/{username}/projectsV2/{project_number}/fields/{field_id} +get:/users/{username}/projectsV2/{project_number}/items +post:/users/{username}/projectsV2/{project_number}/items +delete:/users/{username}/projectsV2/{project_number}/items/{item_id} +get:/users/{username}/projectsV2/{project_number}/items/{item_id} +patch:/users/{username}/projectsV2/{project_number}/items/{item_id} +get:/users/{username}/projectsV2/{project_number}/views/{view_number}/items +get:/users/{username}/received_events +get:/users/{username}/received_events/public +get:/users/{username}/repos +get:/users/{username}/settings/billing/premium_request/usage +get:/users/{username}/settings/billing/usage +get:/users/{username}/settings/billing/usage/summary +get:/users/{username}/social_accounts +get:/users/{username}/ssh_signing_keys +get:/users/{username}/starred +get:/users/{username}/subscriptions +get:/versions +get:/zen diff --git a/crates/atc_router_prefilter/benches/router_bench.rs b/crates/atc_router_prefilter/benches/router_bench.rs new file mode 100644 index 00000000..29be09bf --- /dev/null +++ b/crates/atc_router_prefilter/benches/router_bench.rs @@ -0,0 +1,154 @@ +use atc_router_prefilter::RouterPrefilter; +use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use rand::prelude::*; +use regex::Regex; +use std::fs; +use std::hint::black_box; +use std::sync::LazyLock; + +/// Load GitHub API routes from the paths file +/// Returns a vector of (method, path) tuples +fn load_github_paths() -> Vec<(String, String)> { + let content = + fs::read_to_string("benches/github_paths.txt").expect("Failed to read github_paths.txt"); + + content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let (method, path) = line + .split_once(':') + .expect("every line should have a colon"); + (method.to_string(), path.to_string()) + }) + .collect() +} + +fn path_to_regex(path: &str) -> String { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"\{[^}]*}").unwrap()); + // inaccurate, most segments don't allow `/` + format!("^{}$", RE.replace_all(path, r"(.*)")) +} + +/// Generate paths with version prefixes and optional non-path entries +/// +/// Takes the base GitHub paths and adds `/v{i}/` prefixes to reach at least +/// `expected_paths` total paths. Then converts `non_path_fraction` of the +/// paths to `None` to simulate matches that don't depend on the path at all. +fn generate_path_set( + mut rng: impl Rng, + expected_paths: usize, + non_path_fraction: f64, +) -> Vec { + let paths = load_github_paths(); + let base_paths: Vec = paths.into_iter().map(|(_, path)| path).collect(); + + let base_count = base_paths.len(); + let versions_needed = expected_paths.div_ceil(base_count); + + let mut paths: Vec = Vec::new(); + for version in 0..versions_needed { + for path in &base_paths { + let full_path = format!("/v{}{}", version, path); + paths.push(PathMatch(Some(path_to_regex(&full_path)))); + if paths.len() >= expected_paths { + break; + } + } + } + + // Convert to Option and turn some into None + let non_path_count = (paths.len() as f64 * non_path_fraction).round() as usize; + + // Turn the first `non_path_count` entries into None + for path in paths[..non_path_count].iter_mut() { + path.0 = None; + } + + paths.shuffle(&mut rng); + + paths +} + +#[derive(Debug, Clone)] +struct PathMatch(Option); + +impl Matcher for PathMatch { + fn visit(&self, visitor: &mut MatcherVisitor) { + if let Some(path_regex) = &self.0 { + visitor.visit_match_regex(path_regex); + } + } +} + +fn benchmarks(c: &mut Criterion) { + let mut group = c.benchmark_group("build prefilter"); + let frequency = [ + ("all paths", 0.0), + ("mostly paths", 0.02), + ("half paths", 0.5), + ("rarely paths", 0.99), + ("no paths", 1.0), + ]; + for size in [1, 100, 1_000, 10_000] { + group.throughput(Throughput::Elements(size as u64)); + for (name, frequency) in frequency { + let paths = generate_path_set(StdRng::seed_from_u64(1234), size, frequency); + group.bench_with_input(BenchmarkId::new(name, size), &paths[..], |b, paths| { + b.iter(|| { + let mut prefilter = RouterPrefilter::new(); + for (i, path) in paths.iter().enumerate() { + prefilter.insert(i, path); + } + prefilter + }) + }); + } + } + group.finish(); + let mut group = c.benchmark_group("run prefilter"); + for size in [1, 100, 1_000, 10_000] { + for (name, frequency) in frequency { + let paths = generate_path_set(StdRng::seed_from_u64(1234), size, frequency); + let mut prefilter = RouterPrefilter::new(); + for (i, path) in paths.into_iter().enumerate() { + prefilter.insert(i, path); + } + group.bench_with_input(BenchmarkId::new(name, size), &prefilter, |b, prefilter| { + b.iter(|| { + black_box( + prefilter + .possible_matches("/v1/orgs/MyOrg/attestations/MyAttestation") + .count(), + ); + }) + }); + } + } + group.finish(); + let mut group = c.benchmark_group("overlapping matches"); + let paths = vec![PathMatch(Some("^/all/overlapping".to_string())); 10_000]; + let mut prefilter = RouterPrefilter::new(); + for (i, path) in paths.iter().enumerate() { + prefilter.insert(i, path); + } + group.throughput(Throughput::Elements(paths.len() as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(paths.len()), + &prefilter, + |b, prefilter| { + b.iter(|| { + let mut sum = 0; + for key in prefilter.possible_matches("/all/overlapping/mypath") { + sum += key; + } + sum + }) + }, + ); + group.finish(); +} + +criterion_group!(benches, benchmarks); +criterion_main!(benches); diff --git a/crates/atc_router_prefilter/rustfmt.toml b/crates/atc_router_prefilter/rustfmt.toml new file mode 100644 index 00000000..35011368 --- /dev/null +++ b/crates/atc_router_prefilter/rustfmt.toml @@ -0,0 +1 @@ +style_edition = "2024" diff --git a/crates/atc_router_prefilter/scripts/update_github_urls_for_bench.sh b/crates/atc_router_prefilter/scripts/update_github_urls_for_bench.sh new file mode 100755 index 00000000..5afa6437 --- /dev/null +++ b/crates/atc_router_prefilter/scripts/update_github_urls_for_bench.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +url="https://raw.githubusercontent.com/github/rest-api-description/04fd6c592fc546217404b07e0b0e581fb00a963a/descriptions/api.github.com/api.github.com.json" + +curl -sSL "$url" | jq -r '.paths | to_entries[] | .key as $path | .value | keys[] | "\(.):\($path)"' > benches/github_paths.txt \ No newline at end of file diff --git a/crates/atc_router_prefilter/src/inner_prefilter.rs b/crates/atc_router_prefilter/src/inner_prefilter.rs new file mode 100644 index 00000000..2d85c777 --- /dev/null +++ b/crates/atc_router_prefilter/src/inner_prefilter.rs @@ -0,0 +1,457 @@ +use bstr::BString; +use std::collections::{BTreeMap, BTreeSet}; +use std::iter; +use std::mem; + +#[derive(Debug, Clone)] +struct RadixTrie { + keys: BTreeSet, + children: Vec>, +} + +#[derive(Debug, Clone)] +struct RadixLink { + ch: u8, + rest: BString, + child: RadixTrie, +} + +impl RadixTrie { + fn new() -> Self { + Self { + keys: BTreeSet::new(), + children: Vec::new(), + } + } + + /// Find the child index whose edge label starts with the given byte. + fn find_child(&self, byte: u8) -> Result { + self.children.binary_search_by(|link| link.ch.cmp(&byte)) + } +} + +impl RadixTrie { + fn insert(&mut self, mut prefix: &[u8], key: K) { + let mut node = self; + while let Some(&first_char) = prefix.split_off_first() { + let idx = match node.find_child(first_char) { + Ok(idx) => idx, + Err(idx) => { + node.children.insert( + idx, + RadixLink { + ch: first_char, + rest: BString::new(prefix.to_vec()), + child: RadixTrie::new(), + }, + ); + node = &mut node.children[idx].child; + break; + } + }; + + let link = &mut node.children[idx]; + let common_len = common_prefix_len(&link.rest, prefix); + + if common_len < link.rest.len() { + split_link(link, common_len); + } + + prefix = &prefix[common_len..]; + node = &mut node.children[idx].child; + } + node.keys.insert(key); + } + + fn remove(&mut self, mut prefix: &[u8], key: &K) { + let Some(&first_char) = prefix.split_off_first() else { + self.keys.remove(key); + return; + }; + let Ok(idx) = self.find_child(first_char) else { + return; + }; + + let link = &mut self.children[idx]; + let Some((prefix_rest_begin, prefix_rest)) = prefix.split_at_checked(link.rest.len()) + else { + return; + }; + if prefix_rest_begin != link.rest.as_slice() { + return; + } + + link.child.remove(prefix_rest, key); + + // Clean up empty nodes. + if link.child.keys.is_empty() && link.child.children.is_empty() { + self.children.remove(idx); + } else { + try_compact_link(&mut self.children[idx]); + } + } + + fn collect_prefix_matches(&self, mut input: &[u8]) -> BTreeSet<&K> { + let mut result = BTreeSet::new(); + let mut node = self; + loop { + result.extend(&node.keys); + + let Some(&first_char) = input.split_off_first() else { + break; + }; + + let Ok(idx) = node.find_child(first_char) else { + break; + }; + + let link = &node.children[idx]; + let Some((input_rest_begin, input_rest)) = input.split_at_checked(link.rest.len()) + else { + break; + }; + if input_rest_begin != link.rest.as_slice() { + break; + } + + input = input_rest; + node = &link.child; + } + result + } +} + +fn try_compact_link(link: &mut RadixLink) { + if link.child.keys.is_empty() && link.child.children.len() == 1 { + let grandchild = link.child.children.pop().unwrap(); + link.rest.reserve(1 + grandchild.rest.len()); + link.rest.push(grandchild.ch); + link.rest.extend_from_slice(&grandchild.rest); + link.child = grandchild.child; + } +} + +fn split_link(link: &mut RadixLink, at: usize) { + let tail = link.rest.split_off(at + 1); + let ch = link.rest.pop().unwrap(); + let old_child = mem::replace(&mut link.child, RadixTrie::new()); + link.child.children.push(RadixLink { + ch, + rest: BString::new(tail), + child: old_child, + }); +} + +fn common_prefix_len(lhs: &[u8], rhs: &[u8]) -> usize { + lhs.iter().zip(rhs).take_while(|(a, b)| a == b).count() +} + +/// Internal prefix lookup structure using a radix trie for efficient prefix matching. +/// +/// Stores prefixes mapped to sets of keys, with a reverse index for removal. +#[derive(Debug, Clone)] +pub(crate) struct InnerPrefilter { + prefix_map: RadixTrie, + key_to_prefixes: BTreeMap>, +} + +impl InnerPrefilter { + pub(crate) fn new() -> Self { + Self { + prefix_map: RadixTrie::new(), + key_to_prefixes: BTreeMap::new(), + } + } + + /// Returns true if the prefilter contains no keys. + pub(crate) fn is_empty(&self) -> bool { + self.key_to_prefixes.is_empty() + } + + /// Returns the number of routes in the prefilter. + pub(crate) fn num_routes(&self) -> usize { + self.key_to_prefixes.len() + } +} + +impl InnerPrefilter { + /// Inserts a key with the given prefixes into the prefilter. + /// + /// Each prefix is added to the prefix map, maintaining the prefix-inheritance invariant. + /// + /// No prefix in `prefixes` may be a prefix of another entry in `prefixes`. + /// This precondition is upheld by the caller (`MatcherVisitor::finish` + /// applies `optimize_for_prefix_by_preference`, which collapses such + /// overlapping literals). Violating this causes `remove` to trip a + /// debug assertion. + pub(crate) fn insert(&mut self, key: K, prefixes: Vec>) + where + K: Clone, + { + let prefixes: Vec = prefixes.into_iter().map(BString::new).collect(); + if let Some(old_prefixes) = self.key_to_prefixes.insert(key.clone(), prefixes.clone()) { + for prefix in old_prefixes { + self.prefix_map.remove(&prefix, &key); + } + } + let prefixes_len = prefixes.len(); + // Use repeat_n to avoid cloning the last iteration + for (prefix, key) in prefixes.into_iter().zip(iter::repeat_n(key, prefixes_len)) { + self.prefix_map.insert(&prefix, key); + } + } + + /// Removes a key and all its associated prefixes from the prefilter. + pub(crate) fn remove(&mut self, key: &K) { + let Some(prefixes) = self.key_to_prefixes.remove(key) else { + return; + }; + for prefix in prefixes { + self.prefix_map.remove(&prefix, key); + } + } + + pub(crate) fn clear(&mut self) { + self.key_to_prefixes.clear(); + self.prefix_map = RadixTrie::new(); + } + + /// Checks bytes against the prefilter, returning a set of possible matcher keys. + pub(crate) fn check(&self, bytes: &[u8]) -> BTreeSet<&K> { + self.prefix_map.collect_prefix_matches(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_patterns() { + let prefilter = InnerPrefilter::::new(); + assert_eq!(prefilter.check(b""), BTreeSet::new()); + } + + #[test] + fn test_simple_match() { + let patterns = vec![b"/api/users".to_vec(), b"/api/posts".to_vec()]; + let mut prefilter = InnerPrefilter::new(); + for (i, pattern) in patterns.into_iter().enumerate() { + prefilter.insert(i, vec![pattern]); + } + + let result = prefilter.check(b"/api/users/123"); + assert!(result.contains(&0)); + assert!(!result.contains(&1)); + } + + #[test] + fn test_overlapping_matches() { + let patterns = vec![b"/api".to_vec(), b"/api/v1".to_vec()]; + let indexes = vec![0, 1]; + let mut prefilter = InnerPrefilter::new(); + for (index, pattern) in indexes.into_iter().zip(patterns.into_iter()) { + prefilter.insert(index, vec![pattern]); + } + + let result = prefilter.check(b"/api/v1/users"); + assert!(result.contains(&0)); + assert!(result.contains(&1)); + } + + #[test] + fn test_multiple_same_prefix() { + let patterns = vec![b"/api".to_vec(), b"/api".to_vec(), b"/users".to_vec()]; + let indexes = vec![0, 1, 2]; + let mut prefilter = InnerPrefilter::new(); + for (index, pattern) in indexes.into_iter().zip(patterns.into_iter()) { + prefilter.insert(index, vec![pattern]); + } + + let result = prefilter.check(b"/api/v1"); + assert!(result.contains(&0)); + assert!(result.contains(&1)); + assert!(!result.contains(&2)); + } + + #[test] + fn test_nested_prefixes() { + let patterns = vec![ + b"/".to_vec(), + b"/a".to_vec(), + b"/ab".to_vec(), + b"/abc".to_vec(), + ]; + let indexes = vec![0, 1, 2, 3]; + let mut prefilter = InnerPrefilter::new(); + for (index, pattern) in indexes.into_iter().zip(patterns.into_iter()) { + prefilter.insert(index, vec![pattern]); + } + + let result = prefilter.check(b"/abc/def"); + assert!(result.contains(&0)); + assert!(result.contains(&1)); + assert!(result.contains(&2)); + assert!(result.contains(&3)); + + let result = prefilter.check(b"/ab"); + assert!(result.contains(&0)); + assert!(result.contains(&1)); + assert!(result.contains(&2)); + assert!(!result.contains(&3)); + } + + #[test] + fn test_sparse_prefixes_efficiency() { + // Create a sparse set with many non-matching prefixes + let mut patterns = vec![]; + let mut indexes = vec![]; + + // Add many decoy patterns + for i in 0..100 { + patterns.push(format!("/decoy{:03}", i).into_bytes()); + indexes.push(i); + } + + // Add actual matching patterns + patterns.push(b"/".to_vec()); + patterns.push(b"/target".to_vec()); + indexes.push(1000); + indexes.push(1001); + + let mut prefilter = InnerPrefilter::new(); + for (index, pattern) in indexes.into_iter().zip(patterns.into_iter()) { + prefilter.insert(index, vec![pattern]); + } + let result = prefilter.check(b"/target/resource"); + + assert!(result.contains(&1000)); // "/" matches + assert!(result.contains(&1001)); // "/target" matches + assert_eq!(result.len(), 2); + } + + #[test] + fn test_common_prefix_skipping() { + // Test that common prefix analysis works correctly + let patterns = vec![ + b"/".to_vec(), + b"/api".to_vec(), + b"/api/v999".to_vec(), // Won't match but helps test skipping + b"/other".to_vec(), + ]; + let indexes = vec![0, 1, 2, 3]; + let mut prefilter = InnerPrefilter::new(); + for (index, pattern) in indexes.into_iter().zip(patterns.into_iter()) { + prefilter.insert(index, vec![pattern]); + } + + let result = prefilter.check(b"/api/users/123"); + assert!(result.contains(&0)); // "/" matches + assert!(result.contains(&1)); // "/api" matches + assert!(!result.contains(&2)); // "/api/v999" doesn't match + assert!(!result.contains(&3)); // "/other" doesn't match + assert_eq!(result.len(), 2); + } + + #[test] + fn test_remove() { + let mut prefilter = InnerPrefilter::new(); + prefilter.insert(0, vec![b"/api".to_vec()]); + prefilter.insert(1, vec![b"/api/v1".to_vec()]); + prefilter.insert(2, vec![b"/users".to_vec()]); + + assert!(!prefilter.is_empty()); + assert_eq!(prefilter.num_routes(), 3); + + // Remove a route + prefilter.remove(&1); + assert_eq!(prefilter.num_routes(), 2); + + // Verify it's gone + let result = prefilter.check(b"/api/v1/users"); + assert!(result.contains(&0)); // "/api" still matches + assert!(!result.contains(&1)); // "/api/v1" was removed + + // Remove all routes + prefilter.remove(&0); + prefilter.remove(&2); + assert!(prefilter.is_empty()); + } + + #[test] + fn test_remove_compaction() { + let mut prefilter = InnerPrefilter::new(); + // Build a trie with structure: root -> "a" -> "b" -> "c" (key 0) + // -> "x" (key 1) + // Removing key 1 should compact "a"+"b" into "ab" since the "b" + // node would have no keys and one child. + prefilter.insert(0, vec![b"abc".to_vec()]); + prefilter.insert(1, vec![b"abx".to_vec()]); + prefilter.insert(2, vec![b"a".to_vec()]); + + // Verify all match before removal + assert!(prefilter.check(b"abc_more").contains(&0)); + assert!(prefilter.check(b"abx_more").contains(&1)); + + // Remove key 1 — "ab" node now has one child "c", should compact + prefilter.remove(&1); + // Key 0 must still work after compaction + assert!(prefilter.check(b"abc_more").contains(&0)); + assert!(prefilter.check(b"a_more").contains(&2)); + assert!(!prefilter.check(b"abx_more").contains(&1)); + + // Remove key 2, then key 0 — trie should be fully empty + prefilter.remove(&2); + prefilter.remove(&0); + assert!(prefilter.is_empty()); + } + + #[test] + fn test_edge_split_insert() { + let mut prefilter = InnerPrefilter::new(); + // Insert "abcdef" then "abcxyz" — forces a split at "abc" + prefilter.insert(0, vec![b"abcdef".to_vec()]); + prefilter.insert(1, vec![b"abcxyz".to_vec()]); + + assert!(prefilter.check(b"abcdef_more").contains(&0)); + assert!(prefilter.check(b"abcxyz_more").contains(&1)); + assert!(!prefilter.check(b"abc").contains(&0)); + assert!(!prefilter.check(b"abc").contains(&1)); + + // Insert "abc" — key at the split point itself + prefilter.insert(2, vec![b"abc".to_vec()]); + let result = prefilter.check(b"abcdef_more"); + assert!(result.contains(&0)); + assert!(result.contains(&2)); + assert!(!result.contains(&1)); + + // Insert "ab" — forces another split higher up + prefilter.insert(3, vec![b"ab".to_vec()]); + let result = prefilter.check(b"abcxyz_more"); + assert!(result.contains(&1)); + assert!(result.contains(&2)); + assert!(result.contains(&3)); + assert!(!result.contains(&0)); + } + + #[test] + fn test_is_empty_and_num_routes() { + let mut prefilter = InnerPrefilter::new(); + assert!(prefilter.is_empty()); + assert_eq!(prefilter.num_routes(), 0); + + prefilter.insert(0, vec![b"/api".to_vec()]); + assert!(!prefilter.is_empty()); + assert_eq!(prefilter.num_routes(), 1); + + prefilter.insert(1, vec![b"/users".to_vec()]); + assert_eq!(prefilter.num_routes(), 2); + + prefilter.remove(&0); + assert_eq!(prefilter.num_routes(), 1); + + prefilter.remove(&1); + assert!(prefilter.is_empty()); + assert_eq!(prefilter.num_routes(), 0); + } +} diff --git a/crates/atc_router_prefilter/src/lib.rs b/crates/atc_router_prefilter/src/lib.rs new file mode 100644 index 00000000..e685a741 --- /dev/null +++ b/crates/atc_router_prefilter/src/lib.rs @@ -0,0 +1,840 @@ +#![doc = include_str!("../README.md")] +#![warn(variant_size_differences)] +#![warn(unreachable_pub)] +#![deny(missing_docs)] +#![deny(unsafe_op_in_unsafe_fn)] +#![deny(unnameable_types)] + +mod inner_prefilter; +pub mod matchers; + +use crate::matchers::{Matcher, MatcherVisitor}; +use inner_prefilter::InnerPrefilter; +use std::cmp::Ordering; +use std::collections::{BTreeSet, btree_set}; +use std::iter::FusedIterator; + +/// A prefilter for quickly identifying potentially matching route patterns. +/// +/// The prefilter analyzes route matchers to extract literal prefixes and builds +/// an efficient data structure for fast lookup. Routes without extractable +/// prefixes are tracked separately as always-possible matches. +/// +/// # Examples +/// +/// ``` +/// use atc_router_prefilter::RouterPrefilter; +/// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +/// +/// struct Route { +/// path: String, +/// } +/// +/// impl Matcher for Route { +/// fn visit(&self, visitor: &mut MatcherVisitor) { +/// visitor.visit_match_starts_with(&self.path); +/// } +/// } +/// +/// let routes = vec![ +/// Route { path: "/api".to_string() }, +/// Route { path: "/users".to_string() }, +/// ]; +/// +/// let mut prefilter = RouterPrefilter::new(); +/// for (i, route) in routes.into_iter().enumerate() { +/// prefilter.insert(i, route); +/// } +/// let matches: Vec<_> = prefilter.possible_matches("/api/posts").collect(); +/// assert!(matches.contains(&&0)); +/// ``` +#[derive(Debug)] +pub struct RouterPrefilter { + // Only includes indexes after prefilter starts + always_possible: BTreeSet, + prefilter: InnerPrefilter, + + matcher_visitor: MatcherVisitor, +} + +impl Clone for RouterPrefilter { + fn clone(&self) -> Self { + Self { + always_possible: self.always_possible.clone(), + prefilter: self.prefilter.clone(), + + matcher_visitor: MatcherVisitor::new(), + } + } +} + +impl Default for RouterPrefilter { + fn default() -> Self { + Self::new() + } +} + +impl RouterPrefilter { + /// Creates a new empty prefilter. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// + /// let prefilter: RouterPrefilter = RouterPrefilter::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { + always_possible: BTreeSet::new(), + prefilter: InnerPrefilter::new(), + + matcher_visitor: MatcherVisitor::new(), + } + } + + /// Returns whether this prefilter can perform filtering. + /// + /// Returns `true` if at least one matcher has been inserted with extractable + /// prefixes. Returns `false` if the prefilter is empty or all matchers lack + /// extractable prefixes. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route(&'static str); + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_starts_with(self.0); + /// } + /// } + /// + /// let mut prefilter = RouterPrefilter::new(); + /// assert!(!prefilter.can_prefilter()); + /// + /// prefilter.insert(0, Route("/api")); + /// assert!(prefilter.can_prefilter()); + /// ``` + #[must_use] + pub fn can_prefilter(&self) -> bool { + !self.prefilter.is_empty() + } + + /// Returns the number of routes with extractable prefixes. + /// + /// A "prefilterable" route is one from which literal prefixes can be + /// extracted for fast filtering. Routes without extractable prefixes + /// are tracked separately as always-possible matches and are not + /// counted by this method. + /// + /// A pattern must be anchored at the start and begin with literal + /// characters to have an extractable prefix. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route { + /// pattern: &'static str, + /// } + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_regex(self.pattern); + /// } + /// } + /// + /// let mut prefilter = RouterPrefilter::new(); + /// + /// // Anchored with literal prefix - prefilterable + /// prefilter.insert(0, Route { pattern: r"^/api/.*" }); + /// prefilter.insert(1, Route { pattern: r"^/users/\d+$" }); + /// + /// // Anchored but no literal prefix - not prefilterable + /// prefilter.insert(2, Route { pattern: r"^.*abc" }); + /// prefilter.insert(3, Route { pattern: r"^\d+/api" }); + /// + /// // Not anchored - not prefilterable + /// prefilter.insert(4, Route { pattern: r"/abc/def" }); + /// + /// // Only routes 0 and 1 have extractable literal prefixes + /// assert_eq!(prefilter.prefilterable_routes(), 2); + /// ``` + #[must_use] + pub fn prefilterable_routes(&self) -> usize { + self.prefilter.num_routes() + } +} + +impl RouterPrefilter { + /// Returns the total number of routes in the prefilter. + /// + /// This includes both routes with extractable prefixes and routes + /// tracked as always-possible matches. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route { + /// pattern: &'static str, + /// } + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_regex(self.pattern); + /// } + /// } + /// + /// let mut prefilter = RouterPrefilter::new(); + /// prefilter.insert(0, Route { pattern: r"^/api/.*" }); + /// prefilter.insert(1, Route { pattern: r"^.*abc" }); + /// + /// assert_eq!(prefilter.len(), 2); + /// ``` + #[must_use] + pub fn len(&self) -> usize { + self.prefilter.num_routes() + self.always_possible.len() + } + + /// Returns whether the prefilter contains any routes. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route { + /// pattern: &'static str, + /// } + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_regex(self.pattern); + /// } + /// } + /// + /// let mut prefilter: RouterPrefilter = RouterPrefilter::new(); + /// assert!(prefilter.is_empty()); + /// + /// prefilter.insert(0, Route { pattern: r"^/api/.*" }); + /// assert!(!prefilter.is_empty()); + /// ``` + #[must_use] + pub fn is_empty(&self) -> bool { + self.always_possible.is_empty() && self.prefilter.is_empty() + } + + /// Inserts a matcher with the given key. + /// + /// The matcher is analyzed to extract literal prefixes for fast filtering. + /// If no prefixes can be extracted, the matcher is tracked as always-possible. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route(&'static str); + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_starts_with(self.0); + /// } + /// } + /// + /// let mut prefilter = RouterPrefilter::new(); + /// prefilter.insert(0, Route("/api")); + /// prefilter.insert(1, Route("/users")); + /// ``` + pub fn insert(&mut self, key: K, matcher: M) + where + K: Clone, + { + matcher.visit(&mut self.matcher_visitor); + let prefixes = self.matcher_visitor.finish(); + if let Some(prefixes) = prefixes { + // Clean up in case this key was previously in always_possible + self.always_possible.remove(&key); + let prefixes = prefixes.into_iter().collect(); + self.prefilter.insert(key, prefixes); + } else { + // Clean up in case this key was previously in the prefilter + self.prefilter.remove(&key); + self.always_possible.insert(key); + } + } + + /// Removes a matcher by key. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route(&'static str); + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_starts_with(self.0); + /// } + /// } + /// + /// let mut prefilter = RouterPrefilter::new(); + /// prefilter.insert(0, Route("/api")); + /// prefilter.remove(&0); + /// ``` + pub fn remove(&mut self, key: &K) { + self.always_possible.remove(key); + self.prefilter.remove(key); + } + + /// Removes all routes from the prefilter. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route(&'static str); + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_starts_with(self.0); + /// } + /// } + /// + /// let mut prefilter = RouterPrefilter::new(); + /// prefilter.insert(0, Route("/api")); + /// prefilter.insert(1, Route("/users")); + /// + /// assert_eq!(prefilter.len(), 2); + /// prefilter.clear(); + /// assert!(prefilter.is_empty()); + /// ``` + pub fn clear(&mut self) { + self.always_possible.clear(); + self.prefilter.clear(); + } + + /// Returns an iterator over matcher indexes that may match the given value. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::RouterPrefilter; + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct Route(&'static str); + /// + /// impl Matcher for Route { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_starts_with(self.0); + /// } + /// } + /// + /// let routes = vec![Route("/api"), Route("/users")]; + /// let mut prefilter = RouterPrefilter::new(); + /// for (i, route) in routes.into_iter().enumerate() { + /// prefilter.insert(i, route); + /// } + /// + /// let matches: Vec<_> = prefilter.possible_matches("/api/v1").collect(); + /// assert_eq!(matches, vec![&0]); + /// ``` + #[must_use] + #[doc(alias = "iter")] + pub fn possible_matches<'a>(&'a self, value: &'a str) -> RouterPrefilterIter<'a, K> { + let value = value.as_bytes(); + let filtered_keys = self.prefilter.check(value); + let inner = if filtered_keys.is_empty() { + RouterPrefilterIterState::OnlyAlways(self.always_possible.iter()) + } else { + RouterPrefilterIterState::Union(UnionIter::new( + self.always_possible.iter(), + filtered_keys.into_iter(), + )) + }; + RouterPrefilterIter(inner) + } +} + +/// Iterator over matcher indexes that may match a given value. +/// +/// Created by [`RouterPrefilter::possible_matches`]. Yields matcher indexes +/// in ascending order. +pub struct RouterPrefilterIter<'a, K>(RouterPrefilterIterState<'a, K>); + +enum RouterPrefilterIterState<'a, K> { + OnlyAlways(btree_set::Iter<'a, K>), + Union(UnionIter<'a, K>), +} + +impl<'a, K: Ord> Iterator for RouterPrefilterIter<'a, K> { + type Item = &'a K; + + fn next(&mut self) -> Option { + match &mut self.0 { + RouterPrefilterIterState::OnlyAlways(inner) => inner.next(), + RouterPrefilterIterState::Union(inner) => inner.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match &self.0 { + RouterPrefilterIterState::OnlyAlways(inner) => inner.size_hint(), + RouterPrefilterIterState::Union(inner) => inner.size_hint(), + } + } + + fn fold(self, init: B, f: F) -> B + where + Self: Sized, + F: FnMut(B, Self::Item) -> B, + { + match self.0 { + RouterPrefilterIterState::OnlyAlways(inner) => inner.fold(init, f), + RouterPrefilterIterState::Union(inner) => inner.fold(init, f), + } + } +} + +impl ExactSizeIterator for RouterPrefilterIter<'_, K> {} + +impl FusedIterator for RouterPrefilterIter<'_, K> {} + +impl std::fmt::Debug for RouterPrefilterIter<'_, K> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + RouterPrefilterIterState::OnlyAlways(inner) => { + f.debug_tuple("RouterPrefilterIter").field(inner).finish() + } + RouterPrefilterIterState::Union(inner) => { + f.debug_tuple("RouterPrefilterIter").field(inner).finish() + } + } + } +} + +// Iterator over the union of always and filtered keys +// +// We require that a key will not be in both `always` and `filtered` sets +#[derive(Debug)] +struct UnionIter<'a, K> { + always: btree_set::Iter<'a, K>, + filtered: btree_set::IntoIter<&'a K>, + peeked: Option>, +} + +#[derive(Debug)] +enum Peeked<'a, K> { + Always(&'a K), + Filtered(&'a K), +} + +impl<'a, K> UnionIter<'a, K> { + fn new(always: btree_set::Iter<'a, K>, filtered: btree_set::IntoIter<&'a K>) -> Self { + Self { + always, + filtered, + peeked: None, + } + } +} + +impl<'a, K: Ord> Iterator for UnionIter<'a, K> { + type Item = &'a K; + + fn next(&mut self) -> Option { + let always_next; + let filtered_next; + match self.peeked.take() { + Some(Peeked::Always(next)) => { + always_next = Some(next); + filtered_next = self.filtered.next(); + } + Some(Peeked::Filtered(next)) => { + always_next = self.always.next(); + filtered_next = Some(next); + } + None => { + always_next = self.always.next(); + filtered_next = self.filtered.next(); + } + } + match (always_next, filtered_next) { + (Some(a), Some(f)) => { + let (returned, next_peeked) = match a.cmp(&f) { + Ordering::Less => (a, Peeked::Filtered(f)), + Ordering::Greater => (f, Peeked::Always(a)), + Ordering::Equal => { + unreachable!("keys cannot be both always found and filtered") + } + }; + self.peeked = Some(next_peeked); + Some(returned) + } + (Some(k), None) | (None, Some(k)) => Some(k), + (None, None) => None, + } + } + + fn size_hint(&self) -> (usize, Option) { + // We require non-overlapping values + let len = self.always.len() + self.filtered.len() + usize::from(self.peeked.is_some()); + (len, Some(len)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct TestMatcher { + prefix: Option<&'static str>, + } + + impl TestMatcher { + fn with_prefix(prefix: &'static str) -> Self { + Self { + prefix: Some(prefix), + } + } + + fn without_prefix() -> Self { + Self { prefix: None } + } + } + + impl Matcher for TestMatcher { + fn visit(&self, visitor: &mut MatcherVisitor) { + if let Some(prefix) = self.prefix { + visitor.visit_match_starts_with(prefix); + } + } + } + + #[test] + fn test_iterator_no_skips_before_prefilter() { + let matchers = vec![ + TestMatcher::without_prefix(), + TestMatcher::without_prefix(), + TestMatcher::without_prefix(), + TestMatcher::without_prefix(), + TestMatcher::with_prefix("/api"), + TestMatcher::with_prefix("/users"), + ]; + + let mut prefilter = RouterPrefilter::new(); + for (i, matcher) in matchers.into_iter().enumerate() { + prefilter.insert(i, matcher); + } + let matches: Vec<_> = prefilter.possible_matches("/api/test").collect(); + + assert_eq!(matches, vec![&0, &1, &2, &3, &4]); + } + + #[test] + fn test_mixed_matchers() { + let matchers = vec![ + TestMatcher::without_prefix(), + TestMatcher::without_prefix(), + TestMatcher::without_prefix(), + TestMatcher::with_prefix("/api"), + ]; + + let mut prefilter = RouterPrefilter::new(); + for (i, matcher) in matchers.into_iter().enumerate() { + prefilter.insert(i, matcher); + } + + let matches: Vec<_> = prefilter.possible_matches("/api/test").collect(); + assert_eq!(matches, vec![&0, &1, &2, &3]); + + let matches: Vec<_> = prefilter.possible_matches("/other/path").collect(); + assert_eq!(matches, vec![&0, &1, &2]); + } + + #[test] + fn test_clone() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(1, TestMatcher::without_prefix()); + + let cloned = prefilter.clone(); + let matches: Vec<_> = cloned.possible_matches("/api/test").collect(); + assert_eq!(matches, vec![&0, &1]); + } + + #[test] + fn test_default() { + let prefilter: RouterPrefilter = RouterPrefilter::default(); + assert!(prefilter.is_empty()); + assert!(!prefilter.can_prefilter()); + } + + #[test] + fn test_utility_methods() { + let mut prefilter = RouterPrefilter::new(); + + // Empty state + assert!(prefilter.is_empty()); + assert_eq!(prefilter.len(), 0); + assert!(!prefilter.can_prefilter()); + assert_eq!(prefilter.prefilterable_routes(), 0); + + // Add prefilterable route + prefilter.insert(0, TestMatcher::with_prefix("/api")); + assert!(!prefilter.is_empty()); + assert_eq!(prefilter.len(), 1); + assert!(prefilter.can_prefilter()); + assert_eq!(prefilter.prefilterable_routes(), 1); + + // Add non-prefilterable route + prefilter.insert(1, TestMatcher::without_prefix()); + assert_eq!(prefilter.len(), 2); + assert_eq!(prefilter.prefilterable_routes(), 1); // Still only 1 prefilterable + + // Add another prefilterable route + prefilter.insert(2, TestMatcher::with_prefix("/users")); + assert_eq!(prefilter.len(), 3); + assert_eq!(prefilter.prefilterable_routes(), 2); + } + + #[test] + fn test_remove() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(1, TestMatcher::without_prefix()); + prefilter.insert(2, TestMatcher::with_prefix("/users")); + + assert_eq!(prefilter.len(), 3); + + // Remove prefilterable route + prefilter.remove(&0); + assert_eq!(prefilter.len(), 2); + let matches: Vec<_> = prefilter.possible_matches("/api/test").collect(); + assert!(!matches.contains(&&0)); + assert!(matches.contains(&&1)); + + // Remove non-prefilterable route + prefilter.remove(&1); + assert_eq!(prefilter.len(), 1); + let matches: Vec<_> = prefilter.possible_matches("/users/test").collect(); + assert!(!matches.contains(&&1)); + assert!(matches.contains(&&2)); + + // Remove last route + prefilter.remove(&2); + assert!(prefilter.is_empty()); + } + + #[test] + fn test_iterator_fold() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(1, TestMatcher::with_prefix("/users")); + + let sum = prefilter.possible_matches("/api/test").sum::(); + assert_eq!(sum, 0); // Only route 0 matches + + let sum = prefilter.possible_matches("/users/test").sum::(); + assert_eq!(sum, 1); // Only route 1 matches + } + + #[test] + fn test_iterator_size_hint() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(1, TestMatcher::without_prefix()); + + let iter = prefilter.possible_matches("/api/test"); + let (min, max) = iter.size_hint(); + assert!(min <= max.unwrap_or(usize::MAX)); + } + + #[test] + fn test_iterator_exact_size() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(1, TestMatcher::without_prefix()); + prefilter.insert(2, TestMatcher::with_prefix("/users")); + + // Union case: prefilter result + always_possible + let iter = prefilter.possible_matches("/api/test"); + assert_eq!(iter.len(), 2); // routes 0 and 1 + let (min, max) = iter.size_hint(); + assert_eq!(min, 2); + assert_eq!(max, Some(2)); + + // OnlyAlways case: no prefilter matches + let iter = prefilter.possible_matches("/other/path"); + assert_eq!(iter.len(), 1); // only route 1 + let (min, max) = iter.size_hint(); + assert_eq!(min, 1); + assert_eq!(max, Some(1)); + + // Union case: size_hint must stay accurate after consuming elements + let mut iter = prefilter.possible_matches("/api/test"); + assert_eq!(iter.len(), 2); + iter.next(); + assert_eq!(iter.len(), 1); + iter.next(); + assert_eq!(iter.len(), 0); + } + + #[test] + fn test_iterator_debug() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert("key 123", TestMatcher::with_prefix("/api")); + + let iter = prefilter.possible_matches("/api/test"); + let debug_str = format!("{:?}", iter); + assert!(debug_str.contains("RouterPrefilterIter")); + assert!(debug_str.contains("key 123")); + } + + #[test] + fn test_iterator_fused() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + + let mut iter = prefilter.possible_matches("/api/test"); + + // Exhaust the iterator + assert_eq!(iter.next(), Some(&0)); + assert_eq!(iter.next(), None); + + // FusedIterator guarantees None forever after + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_duplicate_key_insert_replaces_prefix() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(0, TestMatcher::with_prefix("/users")); + + assert_eq!(prefilter.len(), 1); + assert_eq!(prefilter.prefilterable_routes(), 1); + + // Old prefix should no longer match + let matches: Vec<_> = prefilter.possible_matches("/api/test").collect(); + assert!(!matches.contains(&&0)); + + // New prefix should match + let matches: Vec<_> = prefilter.possible_matches("/users/test").collect(); + assert!(matches.contains(&&0)); + } + + #[test] + fn test_duplicate_key_insert_prefilterable_to_always() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(0, TestMatcher::without_prefix()); + + assert_eq!(prefilter.len(), 1); + assert_eq!(prefilter.prefilterable_routes(), 0); + + // Should now be in always_possible, matching everything + let matches: Vec<_> = prefilter.possible_matches("/anything").collect(); + assert!(matches.contains(&&0)); + } + + #[test] + fn test_duplicate_key_insert_always_to_prefilterable() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::without_prefix()); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + + assert_eq!(prefilter.len(), 1); + assert_eq!(prefilter.prefilterable_routes(), 1); + + // Should only match the new prefix + let matches: Vec<_> = prefilter.possible_matches("/api/test").collect(); + assert!(matches.contains(&&0)); + + let matches: Vec<_> = prefilter.possible_matches("/other").collect(); + assert!(!matches.contains(&&0)); + } + + #[test] + fn test_duplicate_key_insert_then_remove() { + let mut prefilter = RouterPrefilter::new(); + prefilter.insert(0, TestMatcher::with_prefix("/api")); + prefilter.insert(0, TestMatcher::with_prefix("/users")); + prefilter.remove(&0); + + assert!(prefilter.is_empty()); + assert_eq!(prefilter.len(), 0); + + // Nothing should match after removal + let matches: Vec<_> = prefilter.possible_matches("/api/test").collect(); + assert!(matches.is_empty()); + let matches: Vec<_> = prefilter.possible_matches("/users/test").collect(); + assert!(matches.is_empty()); + } + + #[test] + fn test_nested_prefix_chain() { + // Each prefix is a prefix of the ones above it, inserted longest-first + let matchers = vec![ + TestMatcher::with_prefix("/a/a/a/a/a/a/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a/a/a/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a/a/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a/a"), + TestMatcher::with_prefix("/a/a/a"), + TestMatcher::with_prefix("/a/a"), + TestMatcher::with_prefix("/a"), + TestMatcher::with_prefix(""), + ]; + + let mut prefilter = RouterPrefilter::new(); + for (i, matcher) in matchers.into_iter().enumerate() { + prefilter.insert(i, matcher); + } + + // Full path matches all prefixes + let matches: Vec<_> = prefilter + .possible_matches("/a/a/a/a/a/a/a/a/a/a/end") + .collect(); + assert_eq!(matches, vec![&0, &1, &2, &3, &4, &5, &6, &7, &8, &9, &10]); + + // Partial path matches only shorter prefixes + let matches: Vec<_> = prefilter.possible_matches("/a/a/a/a/a/z").collect(); + assert_eq!(matches, vec![&5, &6, &7, &8, &9, &10]); + + // Shortest non-empty prefix + let matches: Vec<_> = prefilter.possible_matches("/a/z").collect(); + assert_eq!(matches, vec![&9, &10]); + + // No non-empty prefix matches, but empty prefix is always possible + let matches: Vec<_> = prefilter.possible_matches("/b").collect(); + assert_eq!(matches, vec![&10]); + + // Empty string matches empty prefix (always possible) + let matches: Vec<_> = prefilter.possible_matches("").collect(); + assert_eq!(matches, vec![&10]); + + // Empty prefix goes into always_possible, not the prefilter + assert_eq!(prefilter.prefilterable_routes(), 10); + } +} diff --git a/crates/atc_router_prefilter/src/matchers.rs b/crates/atc_router_prefilter/src/matchers.rs new file mode 100644 index 00000000..521ada13 --- /dev/null +++ b/crates/atc_router_prefilter/src/matchers.rs @@ -0,0 +1,759 @@ +//! Matcher visitor pattern for extracting literal prefixes from route patterns. +//! +//! This module provides the visitor pattern infrastructure that allows route matchers +//! to describe their matching logic, enabling the prefilter to extract literal prefixes +//! for fast filtering. +//! +//! # Core Types +//! +//! - [`Matcher`] - Trait for types that can be analyzed for prefix extraction +//! - [`MatcherVisitor`] - Visitor that extracts literal prefixes from matcher patterns +//! +//! # Example +//! +//! ``` +//! use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +//! +//! struct RoutePattern { +//! prefix: String, +//! } +//! +//! impl Matcher for RoutePattern { +//! fn visit(&self, visitor: &mut MatcherVisitor) { +//! visitor.visit_match_starts_with(&self.prefix); +//! } +//! } +//! ``` + +use regex_syntax::hir::{Hir, literal}; +use std::collections::BTreeSet; +use std::convert::Infallible; +use std::mem; + +/// Describes a pattern matcher that can be analyzed for prefix extraction. +/// +/// Implementors use the [`MatcherVisitor`] to describe their matching logic, +/// allowing the prefilter to extract literal prefixes for fast filtering. +/// +/// See the docs on [`MatcherVisitor`] for information about how to use the visitor describe the +/// requirements of this matcher. +/// +/// # Examples +/// +/// ``` +/// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +/// +/// struct PrefixMatcher { +/// prefix: String, +/// } +/// +/// impl Matcher for PrefixMatcher { +/// fn visit(&self, visitor: &mut MatcherVisitor) { +/// visitor.visit_match_starts_with(&self.prefix); +/// } +/// } +/// ``` +pub trait Matcher { + /// Visits this matcher using the provided visitor. + /// + /// Implementations should call appropriate visitor methods to describe + /// their matching behavior. + fn visit(&self, visitor: &mut MatcherVisitor); +} + +impl Matcher for &M { + fn visit(&self, visitor: &mut MatcherVisitor) { + M::visit(self, visitor); + } +} + +#[derive(Debug)] +struct Frame { + and_literal_prefixes: Option>>, + or_literal_prefixes: Option>>, +} + +impl Default for Frame { + fn default() -> Self { + Self { + and_literal_prefixes: None, + or_literal_prefixes: Some(BTreeSet::new()), + } + } +} + +impl Frame { + fn finish(self) -> Option>> { + let Self { + mut or_literal_prefixes, + and_literal_prefixes, + } = self; + union_prefixes_limited(&mut or_literal_prefixes, and_literal_prefixes, 100); + let prefixes = or_literal_prefixes?; + if prefixes + .first() + .is_none_or(|shortest_prefix| shortest_prefix.is_empty()) + { + return None; + } + Some(prefixes) + } +} + +/// Visitor for extracting literal prefixes from matcher patterns. +/// +/// Extracted prefixes are used to build the prefilter's lookup structure. +/// Instances of this visitor are passed to [`Matcher::visit`] implementations. +/// +/// The visitor methods build a boolean expression over matcher constraints. +/// Like most expression languages, **AND** binds tighter than **OR**: +/// consecutive `visit_match_*` calls are **AND**-ed together, and +/// [`visit_or_in`] separates groups of those calls into alternatives. +/// +/// Use [`visit_nested_start`] and [`visit_nested_finish`] to override +/// precedence, just like parentheses in an expression. The result of a +/// nested group is **AND**-ed with the surrounding context. +/// +/// For example, +/// ``` +/// use router_prefilter::matchers::MatcherVisitor; +/// fn visit(visitor: &mut MatcherVisitor) { +/// visitor.visit_match_starts_with("A"); +/// visitor.visit_match_starts_with("B"); +/// visitor.visit_or_in(); +/// visitor.visit_match_starts_with("C"); +/// visitor.visit_match_starts_with("D"); +/// } +/// ``` +/// is interpreted as `(A && B) || (C && D)`, to instead match as `A && (B | C) && D`, introduce a +/// level of nesting: +/// ``` +/// use router_prefilter::matchers::MatcherVisitor; +/// fn visit(visitor: &mut MatcherVisitor) { +/// visitor.visit_match_starts_with("A"); +/// visitor.visit_nested_start(); +/// visitor.visit_match_starts_with("B"); +/// visitor.visit_or_in(); +/// visitor.visit_match_starts_with("C"); +/// visitor.visit_nested_finish(); +/// visitor.visit_match_starts_with("D"); +/// } +/// ``` +/// +/// # Examples +/// +/// Basic usage with a simple prefix: +/// +/// ``` +/// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +/// +/// enum RouteMatcher { +/// And(Box, Box), +/// Or(Box, Box), +/// Regex(&'static str), +/// } +/// +/// impl Matcher for RouteMatcher { +/// fn visit(&self, visitor: &mut MatcherVisitor) { +/// match self { +/// Self::And(lhs, rhs) => { +/// visitor.visit_nested_start(); +/// lhs.visit(visitor); +/// visitor.visit_nested_finish(); +/// visitor.visit_nested_start(); +/// rhs.visit(visitor); +/// visitor.visit_nested_finish(); +/// } +/// Self::Or(lhs, rhs) => { +/// lhs.visit(visitor); +/// visitor.visit_or_in(); +/// rhs.visit(visitor); +/// } +/// Self::Regex(regex) => visitor.visit_match_regex(regex), +/// } +/// } +/// } +/// ``` +/// +/// Complex pattern with nesting: +/// +/// ``` +/// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +/// +/// struct VersionedRoute; +/// +/// impl Matcher for VersionedRoute { +/// fn visit(&self, visitor: &mut MatcherVisitor) { +/// // /v && (/v1 || /v2) +/// visitor.visit_match_starts_with("/v"); +/// visitor.visit_nested_start(); +/// visitor.visit_match_starts_with("/v1"); +/// visitor.visit_or_in(); +/// visitor.visit_match_starts_with("/v2"); +/// visitor.visit_nested_finish(); +/// } +/// } +/// ``` +/// +/// [`visit_nested_start`]: Self::visit_nested_start +/// [`visit_nested_finish`]: Self::visit_nested_finish +/// [`visit_or_in`]: Self::visit_or_in +#[derive(Debug)] +pub struct MatcherVisitor { + frames: Vec, +} + +impl MatcherVisitor { + pub(crate) fn new() -> Self { + Self { + frames: vec![Frame::default()], + } + } + + fn current_frame(&mut self) -> &mut Frame { + self.frames.last_mut().unwrap() + } + + pub(crate) fn finish(&mut self) -> Option>> { + let Self { frames } = self; + let frame = match &mut frames[..] { + [only_frame] => mem::take(only_frame), + _ => { + frames.clear(); + frames.push(Frame::default()); + panic!("mismatched nesting calls to MatcherVisitor") + } + }; + frame.finish() + } + + /// Begins a nested matching context. + /// + /// Use this to group patterns together for complex matching logic. + /// Must be paired with [`visit_nested_finish`]. + /// + /// Acts as a kind of "parenthesis" for matching logic. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct NestedRoute; + /// + /// impl Matcher for NestedRoute { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// // startsWith("/api") || (contains("abc") && contains("def")) + /// visitor.visit_match_starts_with("/api"); + /// visitor.visit_or_in(); + /// visitor.visit_nested_start(); + /// visitor.visit_match_regex("abc"); + /// visitor.visit_match_regex("def"); + /// visitor.visit_nested_finish(); + /// } + /// } + /// ``` + /// + /// [`visit_nested_finish`]: MatcherVisitor::visit_nested_finish + pub fn visit_nested_start(&mut self) { + self.frames.push(Frame::default()); + } + + /// Completes a nested matching context. + /// + /// Finalizes the pattern grouping started with [`visit_nested_start`]. + /// Must be paired with a preceding [`visit_nested_start`] call. + /// + /// The nested context's extracted prefixes are merged into the parent + /// context using AND semantics. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct NestedRoute; + /// + /// impl Matcher for NestedRoute { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// // startsWith("/api") || (contains("abc") && contains("def")) + /// visitor.visit_match_starts_with("/api"); + /// visitor.visit_or_in(); + /// visitor.visit_nested_start(); + /// visitor.visit_match_regex("abc"); + /// visitor.visit_match_regex("def"); + /// visitor.visit_nested_finish(); + /// } + /// } + /// ``` + /// + /// # Panics + /// + /// Panics if called without a matching [`visit_nested_start`]. + /// + /// [`visit_nested_start`]: MatcherVisitor::visit_nested_start + pub fn visit_nested_finish(&mut self) { + let frame = self + .frames + .pop() + .expect("every finish should match with a start"); + let new_inner = frame.finish(); + intersect_prefix_expansions(&mut self.current_frame().and_literal_prefixes, new_inner); + } + + /// Marks an OR boundary in the current matching context. + /// + /// Use this to separate alternative patterns that should be treated + /// as different matching possibilities. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct MultiVersionRoute; + /// + /// impl Matcher for MultiVersionRoute { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// // (startsWith("/v1") && contains("abc") || (startsWith("/v2") && contains("def")) + /// visitor.visit_match_starts_with("/v1"); + /// visitor.visit_match_regex(r"abc"); + /// visitor.visit_or_in(); + /// visitor.visit_match_starts_with("/v2"); + /// visitor.visit_match_regex(r"def"); + /// } + /// } + /// ``` + pub fn visit_or_in(&mut self) { + let frame = self.current_frame(); + let new_and = frame.and_literal_prefixes.take(); + union_prefixes_limited(&mut frame.or_literal_prefixes, new_and, 100); + } + + /// Processes a regex pattern to extract literal prefixes. + /// + /// Parses the regex and extracts any literal prefixes that can be used + /// for prefiltering. Only anchored patterns yield extractable prefixes. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct RegexRoute(&'static str); + /// + /// impl Matcher for RegexRoute { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_regex(self.0); + /// } + /// } + /// + /// let route = RegexRoute("^/api/.*"); + /// ``` + pub fn visit_match_regex(&mut self, regex: &str) { + let hir = regex_syntax::parse(regex).unwrap_or_else(|_| Hir::fail()); + let current = &mut self.frames.last_mut().unwrap().and_literal_prefixes; + let new_prefixes = extract_prefixes(&hir); + intersect_prefix_expansions(current, new_prefixes); + } + + /// Processes an exact equality match pattern. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct ExactRoute(&'static str); + /// + /// impl Matcher for ExactRoute { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_equals(self.0); + /// } + /// } + /// + /// let route = ExactRoute("/api/users"); + /// ``` + pub fn visit_match_equals(&mut self, equals: &str) { + // for our purposes, equality and starting with are the same + self.visit_match_starts_with(equals); + } + + /// Processes a prefix match pattern. + /// + /// # Examples + /// + /// ``` + /// use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; + /// + /// struct PrefixRoute(&'static str); + /// + /// impl Matcher for PrefixRoute { + /// fn visit(&self, visitor: &mut MatcherVisitor) { + /// visitor.visit_match_starts_with(self.0); + /// } + /// } + /// + /// let route = PrefixRoute("/api"); + /// ``` + pub fn visit_match_starts_with(&mut self, prefix: &str) { + let new_prefixes = Some(BTreeSet::from([prefix.as_bytes().to_vec()])); + let current = &mut self.frames.last_mut().unwrap().and_literal_prefixes; + intersect_prefix_expansions(current, new_prefixes); + } +} + +fn union_prefixes_limited( + lhs: &mut Option>>, + rhs: Option>>, + max_len: usize, +) { + let Some(lhs_inner) = lhs else { + return; + }; + let Some(mut rhs_inner) = rhs else { + *lhs = None; + return; + }; + if rhs_inner.len() > lhs_inner.len() { + mem::swap(lhs_inner, &mut rhs_inner); + } + let mut len = lhs_inner.len(); + for v in rhs_inner { + let did_insert = lhs_inner.insert(v); + len += usize::from(did_insert); + if len > max_len { + *lhs = None; + return; + } + } +} + +/// Computes the prefix-aware intersection of `lhs` and `rhs`, writing matching elements into `dst`. +/// +/// A pair `(l, r)` contributes to `dst` when one is a prefix of the other; the more specific +/// element (the one that starts with the other) is inserted. Both input sets are (at least partly) +/// drained during the operation. +/// +/// This computes the AND of two OR-prefix constraints. Each set represents an OR constraint: +/// the input must start with at least one element in the set. +/// +/// For example, combining `["a", "box", "z"]` and `["apple", "ankle", "bo", "dog"]` +/// yields `["apple", "ankle", "box"]`. If the target string must start with (one of a, box, z) +/// AND (one of apple, ankle, bo, dog), the target string must start with either apple, ankle, +/// or box to match. +/// +/// Returns [`None`] when either set is exhausted, acting as the loop exit signal. The +/// [`Infallible`] bound ensures [`Some`] is never constructed — the `?` operator is used purely +/// for control flow. +fn intersect_prefix_expansions_into( + dst: &mut BTreeSet>, + lhs: &mut BTreeSet>, + rhs: &mut BTreeSet>, +) -> Option { + let mut l = lhs.pop_first()?; + let mut r = rhs.pop_first()?; + + loop { + while l <= r { + if r.starts_with(&l) { + dst.insert(r); + r = rhs.pop_first()?; + } else { + l = lhs.pop_first()?; + } + } + if l.starts_with(&r) { + dst.insert(l); + l = lhs.pop_first()?; + } else { + r = rhs.pop_first()?; + } + } +} + +/// See [`intersect_prefix_expansions_into`] for details +fn intersect_prefix_expansions( + lhs: &mut Option>>, + rhs: Option>>, +) { + let Some(lhs) = lhs else { + *lhs = rhs; + return; + }; + let Some(mut rhs) = rhs else { + return; + }; + + let mut result = BTreeSet::new(); + _ = intersect_prefix_expansions_into(&mut result, lhs, &mut rhs); + *lhs = result; +} + +fn extract_prefixes(hir: &Hir) -> Option>> { + if !hir + .properties() + .look_set_prefix() + .contains_anchor_haystack() + { + return None; + } + let seq = literal::Extractor::new().extract(hir); + seq.literals().map(|literals| { + literals + .iter() + .map(|lit| lit.as_bytes().to_vec()) + .collect::>() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct SimpleMatcher(&'static str); + + impl Matcher for SimpleMatcher { + fn visit(&self, visitor: &mut MatcherVisitor) { + visitor.visit_match_starts_with(self.0); + } + } + + #[test] + fn test_matcher_by_reference() { + let matcher = SimpleMatcher("/api"); + let mut visitor = MatcherVisitor::new(); + #[expect(clippy::needless_borrow)] + (&matcher).visit(&mut visitor); + let prefixes = visitor.finish(); + assert!(prefixes.is_some()); + } + + #[test] + fn test_visit_match_regex_anchored() { + let mut visitor = MatcherVisitor::new(); + visitor.visit_match_regex(r"^/api/.*"); + let prefixes = visitor.finish().unwrap(); + assert_eq!(prefixes.len(), 1); + assert!(prefixes.contains(b"/api/".as_slice())); + } + + #[test] + fn test_visit_match_regex_unanchored() { + let mut visitor = MatcherVisitor::new(); + visitor.visit_match_regex(r"/api/.*"); + // Unanchored regex should not extract prefixes + assert!(visitor.finish().is_none()); + } + + #[test] + fn test_visit_match_equals() { + let mut visitor = MatcherVisitor::new(); + visitor.visit_match_equals("/api/users"); + let prefixes = visitor.finish().unwrap(); + assert_eq!(prefixes.len(), 1); + assert!(prefixes.contains(b"/api/users".as_slice())); + } + + #[test] + fn test_visit_nested() { + let mut visitor = MatcherVisitor::new(); + visitor.visit_nested_start(); + visitor.visit_match_starts_with("/api"); + visitor.visit_nested_finish(); + let prefixes = visitor.finish().unwrap(); + assert_eq!(prefixes.len(), 1); + assert!(prefixes.contains(b"/api".as_slice())); + } + + #[test] + fn test_visit_or_in() { + let mut visitor = MatcherVisitor::new(); + visitor.visit_match_starts_with("/v1"); + visitor.visit_or_in(); + visitor.visit_match_starts_with("/v2"); + let prefixes = visitor.finish().unwrap(); + assert_eq!(prefixes.len(), 2); + assert!(prefixes.contains(b"/v1".as_slice())); + assert!(prefixes.contains(b"/v2".as_slice())); + } + + #[test] + fn test_nested_with_or() { + let mut visitor = MatcherVisitor::new(); + // Match: /v && (/v1 || /v2) + visitor.visit_match_starts_with("/v"); + visitor.visit_nested_start(); + visitor.visit_match_starts_with("/v1"); + visitor.visit_or_in(); + visitor.visit_match_starts_with("/v2"); + visitor.visit_nested_finish(); + let prefixes = visitor.finish().unwrap(); + // Should have /v1 and /v2 (both start with /v) + assert_eq!(prefixes.len(), 2); + assert!(prefixes.contains(b"/v1".as_slice())); + assert!(prefixes.contains(b"/v2".as_slice())); + } + + #[test] + fn test_union_prefixes_limited_exceeds_max() { + let mut lhs = Some(BTreeSet::new()); + let mut rhs = BTreeSet::new(); + + // Fill lhs with 60 items + for i in 0..60 { + lhs.as_mut().unwrap().insert(vec![i]); + } + + // Fill rhs with 60 items + for i in 60..120 { + rhs.insert(vec![i]); + } + + union_prefixes_limited(&mut lhs, Some(rhs), 100); + // Should exceed limit and become None + assert!(lhs.is_none()); + } + + #[test] + fn test_intersect_prefix_expansions_both_none() { + let mut lhs = None; + let rhs = None; + intersect_prefix_expansions(&mut lhs, rhs); + assert!(lhs.is_none()); + } + + #[test] + fn test_intersect_prefix_expansions_lhs_none() { + let mut lhs = None; + let mut rhs = BTreeSet::new(); + rhs.insert(b"/api".to_vec()); + intersect_prefix_expansions(&mut lhs, Some(rhs.clone())); + assert_eq!(lhs, Some(rhs)); + } + + #[test] + fn test_intersect_prefix_expansions_rhs_none() { + let mut lhs_set = BTreeSet::new(); + lhs_set.insert(b"/api".to_vec()); + let mut lhs = Some(lhs_set.clone()); + intersect_prefix_expansions(&mut lhs, None); + // Should remain unchanged when rhs is None + assert_eq!(lhs, Some(lhs_set)); + } + + #[test] + fn test_intersect_prefix_expansions_with_values() { + // Test that intersect finds elements where one is a prefix of the other + let mut lhs_set = BTreeSet::new(); + lhs_set.insert(b"/a".to_vec()); + + let mut rhs_set = BTreeSet::new(); + rhs_set.insert(b"/api".to_vec()); + + let mut lhs = Some(lhs_set); + intersect_prefix_expansions(&mut lhs, Some(rhs_set)); + + // /a is a prefix of /api, so /api should be in the result + let result = lhs.unwrap(); + assert!(result.contains(b"/api".as_slice())); + } + + fn make_set(items: &[&str]) -> BTreeSet> { + let mut result = BTreeSet::new(); + for item in items { + result.insert(item.as_bytes().to_vec()); + } + result + } + + fn run_intersect(lhs: &[&str], rhs: &[&str]) -> BTreeSet> { + let mut dst = BTreeSet::new(); + _ = intersect_prefix_expansions_into(&mut dst, &mut make_set(lhs), &mut make_set(rhs)); + dst + } + + #[test] + fn test_intersect_prefix_expansions_into_doc_example() { + let result = run_intersect(&["a", "box", "z"], &["ankle", "apple", "bo", "dog"]); + assert_eq!(result, make_set(&["ankle", "apple", "box"])); + } + + #[test] + fn test_intersect_prefix_expansions_into_empty_lhs() { + let result = run_intersect(&[], &["abc"]); + assert!(result.is_empty()); + } + + #[test] + fn test_intersect_prefix_expansions_into_empty_rhs() { + let result = run_intersect(&["abc"], &[]); + assert!(result.is_empty()); + } + + #[test] + fn test_intersect_prefix_expansions_into_no_overlap() { + let result = run_intersect(&["abc"], &["xyz"]); + assert!(result.is_empty()); + } + + #[test] + fn test_intersect_prefix_expansions_into_exact_match() { + let result = run_intersect(&["abc"], &["abc"]); + assert_eq!(result, make_set(&["abc"])); + } + + #[test] + fn test_intersect_prefix_expansions_into_lhs_prefix_of_rhs() { + // "ab" is a prefix of "abcd", so "abcd" (the more specific) is inserted + let result = run_intersect(&["ab"], &["abcd"]); + assert_eq!(result, make_set(&["abcd"])); + } + + #[test] + fn test_intersect_prefix_expansions_into_rhs_prefix_of_lhs() { + // "ab" is a prefix of "abcd", so "abcd" (the more specific) is inserted + let result = run_intersect(&["abcd"], &["ab"]); + assert_eq!(result, make_set(&["abcd"])); + } + + #[test] + fn test_intersect_prefix_expansions_into_one_to_many() { + // "a" is a prefix of all three rhs elements + let result = run_intersect(&["a"], &["aa", "ab", "ac"]); + assert_eq!(result, make_set(&["aa", "ab", "ac"])); + } + + #[test] + fn test_intersect_prefix_expansions_into_nested_prefixes_on_one_side() { + // "a" and "aaaaa" are both in lhs, where "a" is a prefix of "aaaaa". + // Any r that would match "aaaaa" also matches "a", so "a" catches it first. + // "aaaaa" contributes nothing extra; the result is still correct. + let result = run_intersect(&["a", "aaaaa", "ba"], &["aaab", "ba"]); + assert_eq!(result, make_set(&["aaab", "ba"])); + } + + #[test] + fn test_intersect_prefix_expansions_into_multiple_lhs_prefixes() { + // "a" matches "ab" from rhs; "b" matches "bcd" from rhs + let result = run_intersect(&["ab", "b"], &["a", "bcd"]); + assert_eq!(result, make_set(&["ab", "bcd"])); + } + + #[test] + fn test_extract_prefixes_anchored() { + let hir = regex_syntax::parse(r"^/api/.*").unwrap(); + let prefixes = extract_prefixes(&hir); + assert!(prefixes.is_some()); + let prefixes = prefixes.unwrap(); + assert!(prefixes.contains(b"/api/".as_slice())); + } + + #[test] + fn test_extract_prefixes_unanchored() { + let hir = regex_syntax::parse(r"/api/.*").unwrap(); + let prefixes = extract_prefixes(&hir); + // Unanchored patterns should return None + assert!(prefixes.is_none()); + } +} diff --git a/lib/resty/router/cdefs.lua b/lib/resty/router/cdefs.lua index 0d0349f0..c21c3893 100644 --- a/lib/resty/router/cdefs.lua +++ b/lib/resty/router/cdefs.lua @@ -64,6 +64,10 @@ bool router_add_matcher(struct Router *router, bool router_remove_matcher(struct Router *router, uintptr_t priority, const int8_t *uuid); +bool router_enable_prefilter(struct Router *router, const uint8_t *field, uint8_t *errbuf, uintptr_t *errbuf_len); + +void router_disable_prefilter(struct Router *router); + bool router_execute(const struct Router *router, struct Context *context); uintptr_t router_get_fields(const struct Router *router, diff --git a/lib/resty/router/router.lua b/lib/resty/router/router.lua index a914a2db..f05d606c 100644 --- a/lib/resty/router/router.lua +++ b/lib/resty/router/router.lua @@ -66,6 +66,21 @@ function _M:remove_matcher(uuid) return clib.router_remove_matcher(self.router, priority, uuid) == true end +function _M:enable_prefilter(field) + local errbuf = get_string_buf(ERR_BUF_MAX_LEN) + local errbuf_len = get_size_ptr() + errbuf_len[0] = ERR_BUF_MAX_LEN + + if clib.router_enable_prefilter(self.router, field, errbuf, errbuf_len) == false then + return nil, ffi_string(errbuf, errbuf_len[0]) + end + + return true +end + +function _M:disable_prefilter() + clib.router_disable_prefilter(self.router) +end function _M:execute(context) assert(context.schema == self.schema) diff --git a/src/ffi/router.rs b/src/ffi/router.rs index 5a365bce..66a87200 100644 --- a/src/ffi/router.rs +++ b/src/ffi/router.rs @@ -147,6 +147,73 @@ pub unsafe extern "C" fn router_remove_matcher( router.remove_matcher(priority, uuid) } +/// Enable prefiltering on the specified field. +/// +/// # Arguments +/// - `router`: a pointer to the [`Router`] object returned by [`router_new`]. +/// - `field`: a pointer to a C-style string representing the field name. +/// - `errbuf`: a buffer to store the error message. +/// - `errbuf_len`: a pointer to the length of the error message buffer. +/// +/// # Returns +/// +/// Returns `true` if the prefilter was added successfully, otherwise `false`, +/// and the error message will be stored in the `errbuf`, +/// and the length of the error message will be stored in `errbuf_len`. +/// +/// # Errors +/// +/// This function will return `false` if the prefilter could not be added to the router, +/// such as invalid field (not in the schema, not a string field). +/// +/// # Panics +/// +/// This function will panic when: +/// +/// - `field` doesn't point to a valid C-style string which is a string field in the router's schema +/// +/// # Safety +/// +/// Violating any of the following constraints will result in undefined behavior: +/// +/// - `router` must be a valid pointer returned by [`router_new`]. +/// - `field` must be a valid pointer to a C-style string, must be properly aligned, +/// and must not have '\0' in the middle. +/// - `errbuf` must be valid to read and write for `*errbuf_len` bytes. +/// - `errbuf_len` must be valid to read and write for `size_of::()` bytes, +/// and it must be properly aligned. +#[no_mangle] +pub unsafe extern "C" fn router_enable_prefilter( + router: &mut Router<&Schema>, + field: *const u8, + errbuf: *mut u8, + errbuf_len: &mut usize, +) -> bool { + let field = ffi::CStr::from_ptr(field as *const c_char) + .to_str() + .unwrap(); + if let Err(e) = router.enable_prefilter(field) { + write_errbuf(e, errbuf, errbuf_len); + return false; + } + true +} + +/// Disable prefiltering. +/// +/// # Arguments +/// - `router`: a pointer to the [`Router`] object returned by [`router_new`]. +/// +/// # Safety +/// +/// Violating any of the following constraints will result in undefined behavior: +/// +/// - `router` must be a valid pointer returned by [`router_new`]. +#[no_mangle] +pub unsafe extern "C" fn router_disable_prefilter(router: &mut Router<&Schema>) { + router.disable_prefilter(); +} + /// Execute the router with the context. /// /// # Arguments diff --git a/src/router.rs b/src/router.rs index ad807d35..201cc225 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,21 +1,34 @@ -use crate::ast::Expression; +use crate::ast::{BinaryOperator, Expression, LogicalExpression, Type}; use crate::context::{Context, Match}; use crate::interpreter::Execute; use crate::parser::parse; use crate::schema::Schema; use crate::semantics::{FieldCounter, Validate}; +use atc_router_prefilter::matchers::{Matcher, MatcherVisitor}; +use atc_router_prefilter::{RouterPrefilter, RouterPrefilterIter}; use std::borrow::Borrow; +use std::cmp::Reverse; use std::collections::{BTreeMap, HashMap}; use uuid::Uuid; -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] struct MatcherKey(usize, Uuid); +#[derive(Debug)] +struct PrefilteredField { + field: String, + // RouterPrefilter returns prefiltered matches in ascending order, but we want to + // visit them in _descending_ order, so higher priority matches are checked first, so + // use Reverse to reverse the sort order. + prefilter: RouterPrefilter>, +} + #[derive(Debug)] pub struct Router { schema: S, matchers: BTreeMap, pub fields: HashMap, + prefiltered_field: Option, } impl Router @@ -30,6 +43,7 @@ where schema, matchers: BTreeMap::new(), fields: HashMap::new(), + prefiltered_field: None, } } @@ -62,6 +76,9 @@ where expr.validate(self.schema())?; expr.add_to_counter(&mut self.fields); + if let Some(filtered_field) = &mut self.prefiltered_field { + filtered_field.insert(key, &expr); + } assert!(self.matchers.insert(key, expr).is_none()); Ok(()) @@ -70,6 +87,10 @@ where pub fn remove_matcher(&mut self, priority: usize, uuid: Uuid) -> bool { let key = MatcherKey(priority, uuid); + if let Some(filtered_field) = &mut self.prefiltered_field { + filtered_field.remove(key); + } + let Some(ast) = self.matchers.remove(&key) else { return false; }; @@ -87,22 +108,189 @@ where true } + fn prefilter_matches<'a>( + &'a self, + context: &'a Context, + ) -> Option>> { + match &self.prefiltered_field { + Some(PrefilteredField { field, prefilter }) if prefilter.can_prefilter() => { + let values = context.value_of(field)?; + let value = values.first()?; + let value = value.as_str()?; + Some(prefilter.possible_matches(value)) + } + _ => None, + } + } + /// Note that unlike `execute`, this doesn't set `Context.result` /// but it also doesn't need a `&mut Context`. pub fn try_match(&self, context: &Context) -> Option { let mut mat = Match::new(); - for (MatcherKey(_, id), m) in self.matchers.iter().rev() { - if m.execute(context, &mut mat) { - mat.uuid = *id; - return Some(mat); + match self.prefilter_matches(context) { + Some(possible_matches) => { + for key in possible_matches { + let key = &key.0; + let expr = &self.matchers[key]; + if expr.execute(context, &mut mat) { + mat.uuid = key.1; + return Some(mat); + } + mat.reset(); + } + } + None => { + for (MatcherKey(_, id), m) in self.matchers.iter().rev() { + if m.execute(context, &mut mat) { + mat.uuid = *id; + return Some(mat); + } + + mat.reset(); + } } - - mat.reset(); } None } + + /// Enable prefiltering on the specified field. + /// + /// This will compile a prefilter for all currently existing matchers, and all future added + /// matchers will be added to the prefilter. This can be an expensive call if there are a lot + /// of matchers. + /// + /// Calling + pub fn enable_prefilter(&mut self, field: &str) -> Result<(), String> { + if let Some(prefiltered_field) = &self.prefiltered_field { + if prefiltered_field.field == field { + // Already prefiltered by this field + return Ok(()); + } + } + match self.schema.borrow().type_of(field) { + Some(Type::String) => {} + Some(actual) => { + return Err(format!( + "Field {field} is of type {actual:?}, must be a string" + )) + } + None => return Err(format!("Field {field} is not in schema")), + } + let mut prefilter = RouterPrefilter::new(); + for (key, expr) in &self.matchers { + prefilter.insert(Reverse(*key), ExprMatcher { expr, field }); + } + self.prefiltered_field = Some(PrefilteredField { + field: field.to_string(), + prefilter, + }); + Ok(()) + } + + /// Disable prefiltering. + pub fn disable_prefilter(&mut self) { + self.prefiltered_field = None; + } +} + +impl PrefilteredField { + fn insert(&mut self, key: MatcherKey, expr: &Expression) { + self.prefilter.insert( + Reverse(key), + ExprMatcher { + expr, + field: &self.field, + }, + ); + } + + fn remove(&mut self, key: MatcherKey) { + self.prefilter.remove(&Reverse(key)); + } +} + +struct ExprMatcher<'a> { + expr: &'a Expression, + field: &'a str, +} + +impl Matcher for ExprMatcher<'_> { + fn visit(&self, visitor: &mut MatcherVisitor) { + match self.expr { + Expression::Logical(logical) => { + visitor.visit_nested_start(); + match logical.as_ref() { + LogicalExpression::And(lhs, rhs) => { + let left_matcher = Self { + expr: lhs, + field: self.field, + }; + let right_matcher = Self { + expr: rhs, + field: self.field, + }; + left_matcher.visit(visitor); + right_matcher.visit(visitor); + } + LogicalExpression::Or(lhs, rhs) => { + let left_matcher = Self { + expr: lhs, + field: self.field, + }; + let right_matcher = Self { + expr: rhs, + field: self.field, + }; + left_matcher.visit(visitor); + visitor.visit_or_in(); + right_matcher.visit(visitor); + } + LogicalExpression::Not(_inner) => { + // can't visit + } + } + visitor.visit_nested_finish(); + } + Expression::Predicate(pred) => { + if pred.lhs.var_name == self.field && pred.lhs.transformations.is_empty() { + match pred.op { + BinaryOperator::Equals => { + let rhs = pred + .rhs + .as_str() + .expect("can only use a prefilter on strings"); + visitor.visit_match_equals(rhs); + } + BinaryOperator::Prefix => { + let rhs = pred + .rhs + .as_str() + .expect("can only use a prefilter on strings"); + visitor.visit_match_starts_with(rhs); + } + BinaryOperator::Regex => { + let rhs = pred + .rhs + .as_regex() + .expect("can only use a prefilter on strings"); + visitor.visit_match_regex(rhs.as_str()); + } + BinaryOperator::NotEquals + | BinaryOperator::Postfix + | BinaryOperator::Greater + | BinaryOperator::GreaterOrEqual + | BinaryOperator::Less + | BinaryOperator::LessOrEqual + | BinaryOperator::In + | BinaryOperator::NotIn + | BinaryOperator::Contains => {} + } + } + } + } + } } #[cfg(test)] diff --git a/t/01-sanity.t b/t/01-sanity.t index 70f007f9..3416d90e 100644 --- a/t/01-sanity.t +++ b/t/01-sanity.t @@ -36,6 +36,9 @@ __DATA__ s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) + r:disable_prefilter() + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ^= \"/foo\" && tcp.port == 80")) @@ -79,6 +82,7 @@ a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(1, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ^= \"/foo\" && tcp.port == 80")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150d", @@ -123,6 +127,7 @@ uuid = a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c prefix = /foo s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ^= \"/foo\" && tcp.port == 80")) @@ -176,6 +181,7 @@ false s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) ngx.say(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path = \"/foo\" && tcp.port == 80")) } @@ -211,6 +217,7 @@ nil --> 1:11 s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ^= \"/foo\" && tcp.port == 80")) diff --git a/t/02-bugs.t b/t/02-bugs.t index c10aae45..aac0bf7a 100644 --- a/t/02-bugs.t +++ b/t/02-bugs.t @@ -99,6 +99,7 @@ ok s:add_field("http.path", "String") local r = router.new(s) + assert(r:enable_prefilter("http.path")) local uuid = "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c" for _, len in ipairs({ diff --git a/t/03-contains.t b/t/03-contains.t index 88e00be3..f59e5675 100644 --- a/t/03-contains.t +++ b/t/03-contains.t @@ -36,6 +36,7 @@ __DATA__ s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path contains \"keyword\" && tcp.port == 80")) @@ -80,6 +81,7 @@ nil s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path contains \"keyword\" && tcp.port == 80")) diff --git a/t/04-rawstr.t b/t/04-rawstr.t index 80cc1d31..c980169d 100644 --- a/t/04-rawstr.t +++ b/t/04-rawstr.t @@ -36,6 +36,7 @@ __DATA__ s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ^= r#\"/foo\"# && tcp.port == 80")) @@ -79,6 +80,7 @@ a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ^= r#\"/foo\"\'\"# && tcp.port == 80")) @@ -123,6 +125,7 @@ a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ~ r#\"^/\\d+/test$\"# && tcp.port == 80")) @@ -166,6 +169,7 @@ a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path ~ r#\"^/\\D+/test$\"# && tcp.port == 80")) diff --git a/t/07-in_notin.t b/t/07-in_notin.t index d925072c..6736b28e 100644 --- a/t/07-in_notin.t +++ b/t/07-in_notin.t @@ -36,6 +36,7 @@ __DATA__ s:add_field("tcp.port", "Int") local r = router.new(s) + assert(r:enable_prefilter("http.path")) ngx.say(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "tcp.port in 80")) diff --git a/t/08-equals.t b/t/08-equals.t index 3f8090a6..613d68e5 100644 --- a/t/08-equals.t +++ b/t/08-equals.t @@ -83,6 +83,7 @@ a921a9aa-ec0e-4cf3-a6cc-8aa5583d150cnilnil s:add_field("http.path", "String") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", "http.path == \"/foo\"")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-8aa5583d150c", diff --git a/t/09-not.t b/t/09-not.t index 518ebbdd..f2edd378 100644 --- a/t/09-not.t +++ b/t/09-not.t @@ -35,6 +35,7 @@ __DATA__ s:add_field("http.path", "String") local r = router.new(s) + assert(r:enable_prefilter("http.path")) assert(r:add_matcher(0, "a921a9aa-ec0e-4cf3-a6cc-1aa5583d150c", [[!(http.path ^= "/abc")]])) diff --git a/t/10-prefilter.t b/t/10-prefilter.t new file mode 100644 index 00000000..8a65d58d --- /dev/null +++ b/t/10-prefilter.t @@ -0,0 +1,80 @@ +# vim:set ft= ts=4 sw=4 et: + +use Test::Nginx::Socket::Lua; +use Cwd qw(cwd); + +repeat_each(2); + +plan tests => repeat_each() * blocks() * 5; + +my $pwd = cwd(); + +our $HttpConfig = qq{ + lua_package_path "$pwd/lib/?.lua;;"; + lua_package_cpath "$pwd/target/debug/?.so;;"; +}; + +no_long_string(); +no_diff(); + +run_tests(); + +__DATA__ + +=== TEST 1: enable_prefilter on field not in schema +--- http_config eval: $::HttpConfig +--- config + location = /t { + content_by_lua_block { + local schema = require("resty.router.schema") + local router = require("resty.router.router") + + local s = schema.new() + s:add_field("http.path", "String") + + local r = router.new(s) + local ok, err = r:enable_prefilter("http.method") + + ngx.say(ok) + ngx.say(err) + } + } +--- request +GET /t +--- response_body +nil +Field http.method is not in schema +--- no_error_log +[error] +[warn] +[crit] + + +=== TEST 2: enable_prefilter on non-String field +--- http_config eval: $::HttpConfig +--- config + location = /t { + content_by_lua_block { + local schema = require("resty.router.schema") + local router = require("resty.router.router") + + local s = schema.new() + s:add_field("http.path", "String") + s:add_field("tcp.port", "Int") + + local r = router.new(s) + local ok, err = r:enable_prefilter("tcp.port") + + ngx.say(ok) + ngx.say(err) + } + } +--- request +GET /t +--- response_body +nil +Field tcp.port is of type Int, must be a string +--- no_error_log +[error] +[warn] +[crit]