diff --git a/.gitignore b/.gitignore index 74b688a..26bc0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ tests/fixture/target/ coverage/ .serena/ shrimp/ +experiments/routes-oracle/target/ diff --git a/HANDOFF.md b/HANDOFF.md index d6da6d3..fdf33b6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,7 +2,34 @@ 세션을 이어받는 에이전트가 먼저 읽는 문서입니다. -## 진행 중 — feature/schema-usr (2026-09-30, API 영향 프로그램 Phase 7c) +## 진행 중 — feature/server-routes (2026-09-30, API 영향 프로그램 Phase 7 후속) + +`rustograph routes --role server`(isthmus http `route-decl` 생산자)와 수확 이름 해석 결함 수정. + +- **routes** — `src/source/routes.rs`(조립·자체 계약 검사) + `routes/{axum,actix,common,pattern,template,validate}.rs`. + axum 0.7·0.8(`specificity`, 버전은 resolve의 패키지 버전으로), actix-web 4(`registration-order`, App마다 group·리소스마다 + index). 규칙과 소스 근거(axum 0.7.9/0.8.9·matchit 0.7.3/0.8.4·actix-web 4.15.0·actix-router 0.5.4·codegen 4.4.0· + tower-http 0.6.11 줄 번호)는 `docs/HTTP-ROUTES.md`. usr는 `harvest_parts`(같은 syn 수확의 트리·아레나)로 해석한 정점 ID라 + `reach --roots-from routes.json`이 그대로 받는다(verify-cli-contract가 확인). +- **오라클** — `experiments/routes-oracle/`(독립 워크스페이스, `[lib] path`가 fixture lib.rs). 세 fixture 정밀도·재현율· + 음성·끝 슬래시 요청 전부 100%, 기록을 `tests/routes.rs`가 오프라인 대조. 오라클이 잡아 고친 것: matchit 중간 파라미터·0.8 + 접두 파라미터의 빈 값 매칭(빈 값 변형), axum이 원문 경로를 비교해 리터럴 중괄호 경로에 인코딩 요청이 안 닿음, actix Trim + 아래 빈 꼬리 변형이 안 닿음. 재기록은 `run_all.sh`. +- **공유 벡터** — isthmus `76b6141`에서 `conformance/`+`conformance.lock` 벤더링. template.grammar·normalize·dispatch.validate· + scope.validate 60건 통과, 나머지는 분류(새 ruleId면 테스트가 실패). +- **수확 수정(bea5155)** — 지역 묶음(let·매개변수·클로저·match 팔·if/while let·for) 스코프 스택(`harvest::locals`), 값 자리의 + 모듈 해석 폐기, `self` 수신자. 자기 분석 가짜 간선 109개 제거(모듈 references 100 + 지역명과 같은 fn 9), tests/fixture 8개. + type 레벨 cycles: 수정 커밋에서 13 → 8, 이 브랜치 끝에서 12(새 routes 모듈의 메서드 → 모듈 함수 투영 4개 — 게이트 아님). +- **e2e(scratch, isthmus 76b6141 무패치 + schemagraph 703a21f)** — 합성 axum+sqlx 백엔드: route 선택 trace 4 chain 모두 route → + 핸들러 → relation-use → 테이블. 경로 호출 hop은 `direct`, 메서드 호출 hop은 `candidate`(syn 이름 팬아웃 — 수확 수정과 무관, + `--semantic`이 필요). 수정 전 바이너리로 같은 입력을 돌리면 지역 `repo`가 모듈 `crate::repo`로 읽혀 checkout·get_order가 + `audit_log`에 **direct**로 닿는 거짓 hop 2개가 생겼고, 수정 후 사라졌다. +- 남은 것 / 알려진 근사(전부 거짓 match 쪽): axum 경로 우선 405 vs isthmus method 우선, actix 라우트 수준 method 가드(405)· + 스코프 포획(뒤 서비스로 안 넘어감)·`web::get()`의 HEAD 미수용, 정규화 미들웨어가 닿지 않게 만든 선언을 strict로 냄. actix + 매크로의 리소스 수준 method 가드에 소비자 `route-decl-path-shadowed` 경고가 날 수 있다(isthmus check로 확인, warning). + impl 메서드 안에서 만든 Router·App은 평가하지 않고 `route-coverage:`로 센다. + +## 직전 — feature/schema-usr (2026-09-30, API 영향 프로그램 Phase 7c, PR #22 머지됨) isthmus `trace`가 Rust 백엔드의 핸들러 도달을 관계 사용과 잇도록 두 가지를 더했다. @@ -160,6 +187,11 @@ PR #8(의미 해석) 70ea82e · #10(의미 하드닝) 011c05b · #12(handoff)· - `src/export.rs` — 결정적 JSON + mermaid + save/load. - `src/sarif.rs` — SARIF 2.1.0(`rustograph/deny` 등 ruleId). - `src/config.rs` — `.rustograph.yml` 파싱(serde_yml 격리), baseline 키. +- `src/source/routes.rs` + `routes/` — isthmus http `route-decl` 생산자(`rustograph routes --role server`). + axum(라우터 값 정적 평가·nest 결합·버전별 문법·빈 값 변형)·actix(App·Scope·Resource·매크로·configure·가드· + NormalizePath)·`pattern`(actix 패턴·정규식 분류)·`template`(정규 템플릿 문법·정규화)·`validate`(order·스코프 검사). + `source::harvest_parts`가 같은 수확의 트리·아레나를 넘긴다. +- `src/harvest/locals.rs` — 본문 지역 묶음 스코프(지역이 아이템보다 먼저). - `src/source/schema.rs` — isthmus bridge-facts 생산자(`rustograph schema`). SQL 문자열·sqlx·diesel table!·DSL 경로·sea_orm에서 relation-use 사실 수확, 산문 오탐 게이트·미해석/unlocated 계수. diff --git a/README.ko.md b/README.ko.md index ad471f1..6856a93 100644 --- a/README.ko.md +++ b/README.ko.md @@ -71,6 +71,7 @@ rustograph dead --exclude-tests # #[cfg(test)] 서브트리 제외 rustograph graph --target x86_64-pc-windows-msvc # cfg(트리플) 평가 rustograph mcp # MCP stdio 서버 — 에이전트가 되묻는 통로 rustograph schema --dir . --out schema-facts.json # isthmus persistence 사실 +rustograph routes --role server --out routes.json # isthmus http route-decl(axum·actix-web) rustograph reach mycrate::api::list_users # isthmus language-traversal(정방향) rustograph impact --format language-traversal --roots-from schema-facts.json # 역방향 ``` @@ -134,6 +135,36 @@ import할 때만 인정합니다. 파싱 실패 파일·문법이 다른 `table! 스캔은 추측하지 않습니다 — 정적으로 해석할 수 없는 것은 지어내지 않고 센 것입니다. +## 서버 라우트 — `routes --role server` + +`rustograph routes --role server`는 axum 0.7·0.8과 actix-web 4 서버가 선언한 +(method, 정규 경로 템플릿)마다 `route-decl` 사실 하나를 담은 isthmus +`bridge-facts` v1 문서(`platform: "rust"`, `target: "http"`)를 냅니다. +핸들러 `symbol.usr`가 `reach`의 정점 ID와 같아서 `isthmus trace`가 route → +핸들러 → relation-use(`schema`) → 테이블(`schemagraph facts`)로 잇습니다. + +- **axum**(`dispatch: "specificity"`, matchit의 정적 > 파라미터 > catch-all): + `Router::new().route(..)` 체인, 메서드 라우터(`get`/`post`/…/`any`/ + `on(MethodFilter)`), `nest`(axum `path_for_nested_route`와 같은 결합), + `merge`, 지역 `let`·재대입, 라우터를 돌려주는 크레이트 함수. 경로 문법은 + 해석된 axum 버전을 따릅니다 — 0.7은 `:id`/`*rest`, 0.8은 `{id}`/`{*rest}`/`{{`. +- **actix-web**(`dispatch: "registration-order"`, App마다 `order.group`, + 리소스마다 `order.index`): `#[get("/x/{id}")]` 매크로, + `web::resource().route(web::get().to(h))`, `web::scope`, `App::route`, + `configure`, 가드(`narrowed`), `{id:\d+}` → `paramConstraints`, `{tail}*` + catch-all, `NormalizePath` 미들웨어의 `trailingSlash` 효과. +- 정적으로 확정하지 못한 것(리터럴이 아닌 경로, 모르는 함수가 만든 라우터, + fallback, tower 서비스)은 dynamic 사실이나 스코프 있는 + `route-coverage:`·`framework-provided-routes:` 한계가 됩니다 — 추측한 + 라우트를 만들지 않습니다. + +규칙마다 근거가 된 axum·matchit·actix-web 소스 줄은 +[docs/HTTP-ROUTES.md](docs/HTTP-ROUTES.md)에 있습니다. 오라클 +(`experiments/routes-oracle/`)이 같은 fixture 소스를 진짜 크레이트로 컴파일해 +프로세스 안에서 요청을 보내 세 fixture 모두 정밀도·재현율 100%를 확인했고, +그 기록을 `cargo test`가 오프라인으로 대조합니다. isthmus 공유 벡터는 +`conformance/`에 잠금 파일과 함께 벤더링했습니다. + ## 순회 문서 — `reach` / `impact --format language-traversal` isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/docs/LANGUAGE-TRAVERSAL.md) diff --git a/README.md b/README.md index 5c97fd9..c255415 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ rustograph mcp --graph .rustograph/graph.json # Emit bridge-facts for isthmus' persistence join (SQL relation uses) rustograph schema --dir . --out schema-facts.json +# Emit isthmus http route-decl facts for axum / actix-web servers +rustograph routes --role server --dir . --out routes.json +rustograph reach --roots-from routes.json # handler usrs are graph vertices + # isthmus language-traversal v1 for `isthmus trace` (many roots, one pass) rustograph reach mycrate::api::list_users mycrate::api::create_user rustograph impact --format language-traversal --roots-from schema-facts.json @@ -229,6 +233,39 @@ and column attributes without a table binding surface as `limitations`, not silence. The name-based scan never guesses: what cannot be resolved statically is counted, not invented. +## Server routes — `routes --role server` + +`rustograph routes --role server` emits an isthmus `bridge-facts` v1 +document with `platform: "rust"`, `target: "http"` and one `route-decl` +fact per (method, canonical path template) that an axum 0.7/0.8 or +actix-web 4 server declares. `isthmus trace` joins it with client calls, +`reach` (the handler `symbol.usr` is the same vertex id), `schema` and +`schemagraph facts` to answer "which tables does this endpoint touch". + +- **axum** (`dispatch: "specificity"`, matchit's static > param > + catch-all order): `Router::new().route(..)` chains, method routers + (`get`/`post`/…/`any`/`on(MethodFilter)`), `nest` (joined like axum's + `path_for_nested_route`), `merge`, local `let`/reassignment and crate + functions that return routers. The path syntax follows the resolved axum + version — `:id`/`*rest` for 0.7, `{id}`/`{*rest}`/`{{` for 0.8. +- **actix-web** (`dispatch: "registration-order"`, one `order.group` per + `App`, one `order.index` per resource): `#[get("/x/{id}")]`-style macros, + `web::resource().route(web::get().to(h))`, `web::scope`, `App::route`, + `configure`, guards (`narrowed`), `{id:\d+}` → `paramConstraints`, + `{tail}*` catch-alls, and the `NormalizePath` middleware's effect on + `trailingSlash`. +- Whatever cannot be resolved statically (non-literal paths, routers built + by unknown functions, fallbacks, tower services) becomes a dynamic fact or + a scoped `route-coverage:` / `framework-provided-routes:` limitation — + never a guessed route. + +Every rule, with the axum/matchit/actix-web source lines that back it, is in +[docs/HTTP-ROUTES.md](docs/HTTP-ROUTES.md). An oracle +(`experiments/routes-oracle/`) compiles the same fixture sources against +the real crates and probes them in-process: 100% precision and recall on +all three fixtures, recorded and checked offline by `cargo test`. The +isthmus conformance vectors are vendored under `conformance/` with a lock. + ## Traversal documents — `reach` / `impact --format language-traversal` ```bash diff --git a/conformance.lock b/conformance.lock new file mode 100644 index 0000000..e46fb05 --- /dev/null +++ b/conformance.lock @@ -0,0 +1,12 @@ +{ + "commit": "76b6141e71c84e0ab1026ad1f18f910b9d966dc8", + "files": { + "http-dispatch.json": "efeecae0622ac8ee4185d10ea117fb9c3c2b2dc504137cae0abd1841f3414f47", + "http-limitation-scope.json": "757200f40b1fa54a30b9486ff24bf5f89c2f7bc4fac26eb9a79d62004a1c847e", + "http-template.json": "770f3a79986560579c94b2548d392eb66ef877dc95b6284211551eeb27cf53e6", + "url-compose.json": "afb689685e3dc987d37ce6b8ff32088604956a67dfe8a7e1556aa462f09a1c7a" + }, + "format": "isthmus-conformance-lock", + "source": "https://github.com/ictechgy/isthmus", + "version": 1 +} diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..790c414 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,19 @@ +# isthmus 공유 적합성 벡터 + +isthmus(`76b6141e71c84e0ab1026ad1f18f910b9d966dc8`)의 `conformance/`를 그대로 가져온 사본이다. 정본은 isthmus가 +소유하며, 이 디렉터리 파일을 직접 고치지 않는다. 갱신할 때는 isthmus main의 파일과 `SHA256SUMS`를 함께 다시 +복사하고(새 suite 파일 포함) 저장소 루트의 `conformance.lock`에 커밋과 파일별 sha256을 적은 뒤 +`cargo test --test routes`로 확인한다. + +rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role server`): + +| suite | ruleId | 검사 | +|---|---|---| +| `http-template` | `template.grammar` | 정규 문법 검사기(`source::routes::template::template_problem`)가 소비자와 같은 판정·거부 사유를 낸다 | +| `http-template` | `template.normalize` | URI 경로 정규화(`normalize_uri_path`)가 같다 | +| `http-dispatch` | `dispatch.validate` | `order` 검증기(`source::routes::validate::order_problem`)가 소비자와 같이 판정한다. fixture 출력에도 적용한다 | +| `http-limitation-scope` | `scope.validate` | 스코프 검증기(`scope_problem`)가 소비자와 같이 판정한다(생산 문서가 거부되지 않게) | + +건너뛰는 사례와 이유: 소비자 전용 사례(`match.*`, `dispatch.match`, `dispatch.shadow`, `scope.applies` — 적용 판정은 +소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose` 전체(클라이언트 +`route-call` 조립 규칙이며 rustograph는 서버 선언만 낸다). diff --git a/conformance/SHA256SUMS b/conformance/SHA256SUMS new file mode 100644 index 0000000..ff9672d --- /dev/null +++ b/conformance/SHA256SUMS @@ -0,0 +1,4 @@ +efeecae0622ac8ee4185d10ea117fb9c3c2b2dc504137cae0abd1841f3414f47 http-dispatch.json +757200f40b1fa54a30b9486ff24bf5f89c2f7bc4fac26eb9a79d62004a1c847e http-limitation-scope.json +770f3a79986560579c94b2548d392eb66ef877dc95b6284211551eeb27cf53e6 http-template.json +afb689685e3dc987d37ce6b8ff32088604956a67dfe8a7e1556aa462f09a1c7a url-compose.json diff --git a/conformance/http-dispatch.json b/conformance/http-dispatch.json new file mode 100644 index 0000000..6bc3aea --- /dev/null +++ b/conformance/http-dispatch.json @@ -0,0 +1,1755 @@ +{ + "format": "isthmus-conformance", + "version": 1, + "suite": "http-dispatch", + "description": "Route dispatch models for the http domain: registration-order validation (order field), consumer binding across order groups and dispatch models, and sound shadowing (route-decl-shadowed and route-decl-path-shadowed). Case ids are stable; producers vendor this file with its sha256 from SHA256SUMS.", + "contract": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "cases": [ + { + "id": "validate/order-accepted", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/{}/", + "order": { + "group": "django:shop.urls", + "index": 0 + } + }, + { + "method": "ANY", + "channel": "/items/featured/", + "order": { + "group": "django:shop.urls", + "index": 1 + } + } + ] + } + }, + "expect": { + "valid": true + } + }, + { + "id": "validate/index-shared-by-one-registration", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "note": "One registration (one source location) may emit several facts (methods, expanded templates) with the same index.", + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": 3 + }, + "location": { + "line": 7, + "column": 10 + } + }, + { + "method": "POST", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": 3 + }, + "location": { + "line": 7, + "column": 10 + } + } + ] + } + }, + "expect": { + "valid": true + } + }, + { + "id": "validate/order-optional", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "note": "A declaration whose order was not proven omits order; it is never ordered against other declarations.", + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": 0 + } + }, + { + "method": "GET", + "channel": "/legacy/" + } + ] + } + }, + "expect": { + "valid": true + } + }, + { + "id": "validate/order-requires-registration-order", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "specificity", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": 0 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/index-negative", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": -1 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/index-fraction", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": 1.5 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/index-string", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": "1" + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/group-empty", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "", + "index": 0 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/group-surrounding-whitespace", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": " django:shop.urls", + "index": 0 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/group-too-long", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + "index": 0 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/group-not-string", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": 3, + "index": 0 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/order-extra-key", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls", + "index": 0, + "depth": 1 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/order-missing-index", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "django:shop.urls" + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/group-max-length", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/items/", + "order": { + "group": "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + "index": 0 + } + } + ] + } + }, + "expect": { + "valid": true + } + }, + { + "id": "validate/index-shared-by-two-registrations", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/a/", + "order": { + "group": "django:shop.urls", + "index": 4 + }, + "location": { + "line": 3, + "column": 10 + } + }, + { + "method": "GET", + "channel": "/b/", + "order": { + "group": "django:shop.urls", + "index": 4 + }, + "location": { + "line": 9, + "column": 10 + } + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/group-across-services", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/a/", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "service": "shop" + }, + { + "method": "GET", + "channel": "/b/", + "order": { + "group": "django:shop.urls", + "index": 2 + }, + "service": "blog" + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/catch-all-prefix-inherits-order", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/files/{**}", + "order": { + "group": "django:shop.urls", + "index": 5 + }, + "symbol": { + "qualifiedName": "files", + "usr": "shop/views.py#files" + }, + "location": { + "line": 12, + "column": 10 + } + }, + { + "method": "GET", + "channel": "/files", + "order": { + "group": "django:shop.urls", + "index": 5 + }, + "symbol": { + "qualifiedName": "files", + "usr": "shop/views.py#files" + }, + "location": { + "line": 12, + "column": 10 + }, + "catchAllPrefix": true + } + ] + } + }, + "expect": { + "valid": true + } + }, + { + "id": "validate/catch-all-prefix-other-order", + "ruleId": "dispatch.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "document": { + "dispatch": "registration-order", + "facts": [ + { + "method": "GET", + "channel": "/files/{**}", + "order": { + "group": "django:shop.urls", + "index": 5 + }, + "symbol": { + "qualifiedName": "files", + "usr": "shop/views.py#files" + }, + "location": { + "line": 12, + "column": 10 + } + }, + { + "method": "GET", + "channel": "/files", + "order": { + "group": "django:shop.urls", + "index": 6 + }, + "symbol": { + "qualifiedName": "files", + "usr": "shop/views.py#files" + }, + "location": { + "line": 12, + "column": 10 + }, + "catchAllPrefix": true + } + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "match/first-registration-wins", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "note": "Specificity would pick the literal; registration order picks the earlier parameter declaration.", + "input": { + "decls": [ + { + "method": "GET", + "template": "/items/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 5 + } + }, + { + "method": "GET", + "template": "/items/featured/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 6 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/items/featured/" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /items/{}/" + ] + } + }, + { + "id": "match/literal-registered-first", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/items/featured/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 5 + } + }, + { + "method": "GET", + "template": "/items/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 6 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/items/featured/" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /items/featured/" + ] + } + }, + { + "id": "match/call-param-skips-earlier-literal", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/me/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/users/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/users/{}/" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/{}/" + ] + } + }, + { + "id": "match/call-param-literals-only-ambiguous", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/codes/you/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/codes/me/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/codes/{}/" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /codes/you/", + "GET /codes/me/" + ] + } + }, + { + "id": "match/earlier-regex-may-intercept", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/years/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + }, + "paramConstraints": [ + { + "segment": 1, + "kind": "regex", + "pattern": "[0-9]{4}" + } + ] + }, + { + "method": "GET", + "template": "/years/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 4 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/years/2024/" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal-constrained", + "targets": [ + "GET /years/{}/", + "GET /years/{}/" + ] + } + }, + { + "id": "match/method-filtered-first", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "note": "Methods are filtered before order, so a path-first framework may answer 405 here: a false match, never a false error.", + "input": { + "decls": [ + { + "method": "GET", + "template": "/items/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "ANY", + "template": "/items/featured/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "POST", + "template": "/items/featured/" + } + }, + "expect": { + "status": "matched", + "quality": "any-method", + "targets": [ + "ANY /items/featured/" + ] + } + }, + { + "id": "match/method-mismatch-unchanged", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/reports/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/reports/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "POST", + "template": "/reports/" + } + }, + "expect": { + "status": "method-mismatch", + "provable": true + } + }, + { + "id": "match/narrowed-falls-through", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/late/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "narrowed": true + }, + { + "method": "GET", + "template": "/late/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/late/" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /late/", + "GET /late/" + ] + } + }, + { + "id": "match/groups-not-compared", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "note": "Orders are compared only inside one document and one group; the same group string in two documents is two chains.", + "input": { + "decls": [ + { + "method": "GET", + "template": "/items/featured/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 9 + } + }, + { + "method": "GET", + "template": "/items/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "document": 1 + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/items/featured/" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /items/featured/", + "GET /items/{}/" + ] + } + }, + { + "id": "match/unordered-not-compared", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/free/{}/", + "trailingSlash": "strict" + }, + { + "method": "GET", + "template": "/free/x/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/free/x/" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /free/{}/", + "GET /free/x/" + ] + } + }, + { + "id": "match/across-dispatch-models-ambiguous", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/mixed/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + } + }, + { + "method": "GET", + "template": "/mixed/me/", + "dispatch": "specificity" + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/mixed/me/" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /mixed/{}/", + "GET /mixed/me/" + ] + } + }, + { + "id": "match/definite-unit-beats-conditional-unit", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/mixed/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + } + }, + { + "method": "GET", + "template": "/mixed/me/", + "dispatch": "specificity" + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/mixed/{}/" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /mixed/{}/" + ] + } + }, + { + "id": "match/suffix-drops-shadowed", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "note": "A fully shadowed declaration receives no request under any base, so it is not a suffix candidate.", + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/companies/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/api/companies/tech/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ], + "call": { + "pathAnchor": "base", + "method": "GET", + "template": "/companies/tech/" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /api/companies/{}/" + ] + } + }, + { + "id": "match/shadowed-never-target", + "ruleId": "dispatch.match", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "note": "The earlier partial segment is unprovable for this call, but the later declaration is fully shadowed and receives no request.", + "input": { + "decls": [ + { + "method": "GET", + "template": "/p/{}t", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 0 + } + }, + { + "method": "GET", + "template": "/p/ab{}t", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + } + ], + "call": { + "pathAnchor": "root", + "method": "GET", + "template": "/p/ab{}t" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal", + "targets": [ + "GET /p/{}t" + ] + } + }, + { + "id": "shadow/path-only-when-methods-differ", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "note": "A path-first framework (Django) answers 405 at the earlier registration; a method-aware router still reaches the later one.", + "input": { + "decls": [ + { + "method": "GET", + "template": "/items/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 5 + } + }, + { + "method": "ANY", + "template": "/items/featured/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 6 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [ + "ANY /items/featured/ @6" + ] + } + }, + { + "id": "shadow/full-when-method-covered", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/items/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 5 + } + }, + { + "method": "GET", + "template": "/items/new/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 6 + } + } + ] + }, + "expect": { + "shadowed": [ + "GET /items/new/ @6" + ], + "pathShadowed": [] + } + }, + { + "id": "shadow/any-covers-every-method", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "ANY", + "template": "/dup/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 8 + } + }, + { + "method": "ANY", + "template": "/dup/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 9 + } + }, + { + "method": "DELETE", + "template": "/dup/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 10 + } + } + ] + }, + "expect": { + "shadowed": [ + "ANY /dup/ @9", + "DELETE /dup/ @10" + ], + "pathShadowed": [] + } + }, + { + "id": "shadow/closed-constraint-not-evaluated-on-literal", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "paramConstraints": [ + { + "segment": 1, + "kind": "int" + } + ] + }, + { + "method": "GET", + "template": "/a/1/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [] + } + }, + { + "id": "shadow/same-closed-constraint", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "paramConstraints": [ + { + "segment": 1, + "kind": "int" + } + ] + }, + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + }, + "paramConstraints": [ + { + "segment": 1, + "kind": "int" + } + ] + } + ] + }, + "expect": { + "shadowed": [ + "GET /a/{}/ @2" + ], + "pathShadowed": [] + } + }, + { + "id": "shadow/regex-covers-nothing", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "paramConstraints": [ + { + "segment": 1, + "kind": "regex", + "pattern": "[a-z]+" + } + ] + }, + { + "method": "GET", + "template": "/a/b/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [] + } + }, + { + "id": "shadow/optional-later-needs-optional-earlier", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/a/b", + "trailingSlash": "optional", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [] + } + }, + { + "id": "shadow/optional-covers-optional", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}", + "trailingSlash": "optional", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/a/b", + "trailingSlash": "optional", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ] + }, + "expect": { + "shadowed": [ + "GET /a/b @2" + ], + "pathShadowed": [] + } + }, + { + "id": "shadow/catch-all-covers-non-empty-rest", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/docs/{**}", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/docs/api/{}", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + }, + { + "method": "GET", + "template": "/docs/a/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + } + } + ] + }, + "expect": { + "shadowed": [ + "GET /docs/api/{} @2" + ], + "pathShadowed": [] + } + }, + { + "id": "shadow/narrowed-never-shadows", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/late/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + }, + "narrowed": true + }, + { + "method": "GET", + "template": "/late/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [] + } + }, + { + "id": "shadow/same-index-is-one-registration", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + } + }, + { + "method": "GET", + "template": "/a/b/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [] + } + }, + { + "id": "shadow/other-group-not-compared", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}/", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/a/b/", + "trailingSlash": "strict", + "order": { + "group": "other", + "index": 2 + } + } + ] + }, + "expect": { + "shadowed": [], + "pathShadowed": [] + } + }, + { + "id": "shadow/partial-segment-covers-literal-and-narrower-skeleton", + "ruleId": "dispatch.shadow", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#디스패치-모델", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/f/{}.json", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 1 + } + }, + { + "method": "GET", + "template": "/f/a.json", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 2 + } + }, + { + "method": "GET", + "template": "/f/x{}.json", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 3 + } + }, + { + "method": "GET", + "template": "/f/{}.xml", + "trailingSlash": "strict", + "order": { + "group": "django:shop.urls", + "index": 4 + } + } + ] + }, + "expect": { + "shadowed": [ + "GET /f/a.json @2", + "GET /f/x{}.json @3" + ], + "pathShadowed": [] + } + } + ] +} diff --git a/conformance/http-limitation-scope.json b/conformance/http-limitation-scope.json new file mode 100644 index 0000000..d335586 --- /dev/null +++ b/conformance/http-limitation-scope.json @@ -0,0 +1,689 @@ +{ + "format": "isthmus-conformance", + "version": 1, + "suite": "http-limitation-scope", + "description": "Consumer reading of http limitationScopes (templates, templatePrefixes, templateSuffixes, methods): which calls (server/contract-side gaps) or declarations (client-side gaps) a scoped limitation applies to, and which scope entries are rejected. Comparisons are conservative over-approximations. Case ids are stable; producers vendor this file with its sha256 from SHA256SUMS.", + "contract": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "cases": [ + { + "id": "applies/template-exact", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/error" + ] + }, + "probe": { + "template": "/error", + "method": "PUT", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/template-other-path", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/error" + ] + }, + "probe": { + "template": "/api/orders", + "method": "POST", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": false + }, + "note": "A scoped limitation does not block error judgement for calls outside its scope." + }, + { + "id": "applies/template-trailing-slash-folded", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/error" + ] + }, + "probe": { + "template": "/error/", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + }, + "note": "One trailing slash is ignored in scope comparisons (conservative)." + }, + { + "id": "applies/template-case-folded", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/error" + ] + }, + "probe": { + "template": "/ERROR", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + }, + "note": "ASCII case is folded in scope comparisons (conservative); producers need not list case variants." + }, + { + "id": "applies/call-param-may-hit-literal", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/error" + ] + }, + "probe": { + "template": "/{}", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + }, + "note": "A call parameter can take any value, so it may reach a literal scope element." + }, + { + "id": "applies/template-catch-all-zero-segments", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/files/{**}" + ] + }, + "probe": { + "template": "/files", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + }, + "note": "{**} in a scope element means zero or more segments." + }, + { + "id": "applies/prefix-covers-subtree", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templatePrefixes": [ + "/actuator" + ] + }, + "probe": { + "template": "/actuator/health/{}", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/prefix-segment-boundary", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templatePrefixes": [ + "/actuator" + ] + }, + "probe": { + "template": "/actuators", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "applies/root-prefix-covers-everything", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templatePrefixes": [ + "/" + ] + }, + "probe": { + "template": "/any/path", + "method": "DELETE", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/suffix-after-unknown-prefix", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templateSuffixes": [ + "/items/{}" + ] + }, + "probe": { + "template": "/v2/items/7", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/suffix-other-tail", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templateSuffixes": [ + "/items/{}" + ] + }, + "probe": { + "template": "/v2/items/7/extra", + "method": "GET", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "applies/base-call-unknown-prefix", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/app/error" + ] + }, + "probe": { + "template": "/error", + "method": "GET", + "pathAnchor": "base", + "side": "call" + } + }, + "expect": { + "applies": true + }, + "note": "A base-anchored call has an unknown leading path, so any scope element ending with its template may overlap." + }, + { + "id": "applies/methods-exclude-other-verbs", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "GET", + "HEAD" + ] + }, + "probe": { + "template": "/api/orders", + "method": "POST", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": false + }, + "note": "Spring static resources (GET/HEAD under /**) do not block POST judgements." + }, + { + "id": "applies/methods-head-via-get", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/a" + ], + "methods": [ + "GET" + ] + }, + "probe": { + "template": "/a", + "method": "HEAD", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/methods-options-always", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/a" + ], + "methods": [ + "GET" + ] + }, + "probe": { + "template": "/a", + "method": "OPTIONS", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/methods-dynamic-verb", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/a" + ], + "methods": [ + "GET" + ] + }, + "probe": { + "template": "/a", + "pathAnchor": "root", + "side": "call" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "applies/declaration-side-head", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/items" + ], + "methods": [ + "HEAD" + ] + }, + "probe": { + "template": "/items", + "method": "GET", + "pathAnchor": "root", + "side": "declaration" + } + }, + "expect": { + "applies": true + }, + "note": "Client-side scopes describe hidden calls; a HEAD call can reach a GET declaration." + }, + { + "id": "applies/declaration-side-other-verb", + "ruleId": "scope.applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/items" + ], + "methods": [ + "HEAD" + ] + }, + "probe": { + "template": "/items", + "method": "POST", + "pathAnchor": "root", + "side": "declaration" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "validate/channels-rejected", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "channels": [ + "/error" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/path-field-required", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "methods": [ + "GET" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/non-canonical-template", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/a/{id}" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/prefix-catch-all", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templatePrefixes": [ + "/files/{**}" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/prefix-trailing-slash", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templatePrefixes": [ + "/actuator/" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/root-suffix", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templateSuffixes": [ + "/" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/any-method", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/a" + ], + "methods": [ + "ANY" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/unknown-key", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/a" + ], + "hosts": [ + "example.test" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "validate/spring-web-defaults", + "ruleId": "scope.validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "scope": { + "templates": [ + "/error" + ] + } + }, + "expect": { + "valid": true + } + } + ] +} diff --git a/conformance/http-template.json b/conformance/http-template.json new file mode 100644 index 0000000..a984886 --- /dev/null +++ b/conformance/http-template.json @@ -0,0 +1,2225 @@ +{ + "format": "isthmus-conformance", + "version": 1, + "suite": "http-template", + "description": "Canonical path template grammar, producer normalization, framework conversion, and consumer segment matching for the http domain. Case ids are stable; producers vendor this file with its sha256 from SHA256SUMS.", + "contract": "docs/GRAPH-EXCHANGE.md#개발-중-http-경계-합의-초안", + "cases": [ + { + "id": "grammar/root", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/empty-segments-preserved", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/api//items/" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/case-preserved", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/Items/ID" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/whole-param", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/items/{}" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/partial-segment", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/files/{}.json" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/catch-all-last", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/files/{**}" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/root-catch-all", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/{**}" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/pchar-sub-delims", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/a:b@c!$&'()*+,;=-._~" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/uppercase-percent", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/caf%C3%A9" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/encoded-braces", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/%7B%7D" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/encoded-slash-kept", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/a%2Fb" + }, + "expect": { + "valid": true + } + }, + { + "id": "grammar/not-rooted", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "items" + }, + "expect": { + "valid": false, + "reason": "not-rooted" + } + }, + { + "id": "grammar/space", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/a b" + }, + "expect": { + "valid": false, + "reason": "invalid-character" + } + }, + { + "id": "grammar/raw-non-ascii", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/café" + }, + "expect": { + "valid": false, + "reason": "invalid-character" + } + }, + { + "id": "grammar/query-not-stripped-by-consumer", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/items?page=1" + }, + "expect": { + "valid": false, + "reason": "invalid-character" + } + }, + { + "id": "grammar/fragment", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/items#top" + }, + "expect": { + "valid": false, + "reason": "invalid-character" + } + }, + { + "id": "grammar/lowercase-hex", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/a%2fb" + }, + "expect": { + "valid": false, + "reason": "lowercase-percent-hex" + } + }, + { + "id": "grammar/encoded-unreserved", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/%41bc" + }, + "expect": { + "valid": false, + "reason": "encoded-unreserved" + } + }, + { + "id": "grammar/encoded-tilde", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/%7E" + }, + "expect": { + "valid": false, + "reason": "encoded-unreserved" + } + }, + { + "id": "grammar/malformed-percent", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/%2" + }, + "expect": { + "valid": false, + "reason": "malformed-percent" + } + }, + { + "id": "grammar/named-param-unconverted-brace", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/items/{id}" + }, + "expect": { + "valid": false, + "reason": "stray-brace" + } + }, + { + "id": "grammar/stray-open", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/{" + }, + "expect": { + "valid": false, + "reason": "stray-brace" + } + }, + { + "id": "grammar/two-params-one-segment", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/v{}.n{}" + }, + "expect": { + "valid": false, + "reason": "multiple-parameters" + } + }, + { + "id": "grammar/catch-all-partial", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/a{**}" + }, + "expect": { + "valid": false, + "reason": "catch-all-partial" + } + }, + { + "id": "grammar/catch-all-middle", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/{**}/a" + }, + "expect": { + "valid": false, + "reason": "catch-all-not-last" + } + }, + { + "id": "grammar/catch-all-before-trailing-slash", + "ruleId": "template.grammar", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "template": "/{**}/" + }, + "expect": { + "valid": false, + "reason": "catch-all-not-last" + } + }, + { + "id": "normalize/uppercase-hex", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "/caf%c3%a9" + }, + "expect": { + "template": "/caf%C3%A9" + } + }, + { + "id": "normalize/decode-unreserved", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "/%41bc/%7euser" + }, + "expect": { + "template": "/Abc/~user" + } + }, + { + "id": "normalize/encode-non-ascii", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "/café" + }, + "expect": { + "template": "/caf%C3%A9" + } + }, + { + "id": "normalize/encode-space", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "/a b" + }, + "expect": { + "template": "/a%20b" + } + }, + { + "id": "normalize/literal-braces", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "/tpl/{x}" + }, + "expect": { + "template": "/tpl/%7Bx%7D" + }, + "note": "A literal brace in a client path is not a parameter; producers encode it. Server parameter syntax is converted by the framework table, not by this rule." + }, + { + "id": "normalize/keep-encoded-slash", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "/a%2Fb" + }, + "expect": { + "template": "/a%2Fb" + }, + "note": "An encoded slash stays inside one segment." + }, + { + "id": "normalize/keep-duplicate-and-trailing-slash", + "ruleId": "template.normalize", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer" + ], + "input": { + "path": "//a/" + }, + "expect": { + "template": "//a/" + } + }, + { + "id": "openapi/whole-segment", + "ruleId": "framework.openapi.path-templating", + "provenance": "verified-doc", + "source": "https://spec.openapis.org/oas/v3.1.0#path-templating", + "versionRange": ">=2.0", + "appliesTo": [ + "producer:openapi" + ], + "input": { + "framework": "openapi", + "path": "/items/{itemId}" + }, + "expect": { + "template": "/items/{}" + } + }, + { + "id": "openapi/two-params", + "ruleId": "framework.openapi.path-templating", + "provenance": "verified-doc", + "source": "https://spec.openapis.org/oas/v3.1.0#path-templating", + "versionRange": ">=2.0", + "appliesTo": [ + "producer:openapi" + ], + "input": { + "framework": "openapi", + "path": "/users/{userId}/items/{itemId}" + }, + "expect": { + "template": "/users/{}/items/{}" + } + }, + { + "id": "openapi/literal-segment", + "ruleId": "framework.openapi.path-templating", + "provenance": "verified-doc", + "source": "https://spec.openapis.org/oas/v3.1.0#path-templating", + "versionRange": ">=2.0", + "appliesTo": [ + "producer:openapi" + ], + "input": { + "framework": "openapi", + "path": "/items/featured" + }, + "expect": { + "template": "/items/featured" + } + }, + { + "id": "openapi/two-params-one-segment", + "ruleId": "framework.openapi.path-templating", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer:openapi" + ], + "input": { + "framework": "openapi", + "path": "/v{major}.{minor}" + }, + "expectDynamic": true, + "note": "A segment with two parameters has no skeleton in the canonical grammar; emit the operation as dynamic plus contract-coverage:." + }, + { + "id": "openapi/partial-segment", + "ruleId": "framework.openapi.path-templating", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer:openapi" + ], + "input": { + "framework": "openapi", + "path": "/reports/{id}.pdf" + }, + "expect": { + "template": "/reports/{}.pdf" + } + }, + { + "id": "match/literal-exact", + "ruleId": "match.exact", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/me" + }, + { + "method": "GET", + "template": "/users/{}" + } + ], + "call": { + "method": "GET", + "template": "/users/me", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/me" + ] + } + }, + { + "id": "match/call-param-prefers-decl-param", + "ruleId": "match.param", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/me" + }, + { + "method": "GET", + "template": "/users/{}" + } + ], + "call": { + "method": "GET", + "template": "/users/{}", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/{}" + ] + } + }, + { + "id": "match/call-literal-to-param", + "ruleId": "match.param", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/{}" + } + ], + "call": { + "method": "GET", + "template": "/users/42", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/{}" + ] + } + }, + { + "id": "match/param-to-literal-only", + "ruleId": "match.param-to-literal", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/articles/featured" + } + ], + "call": { + "method": "GET", + "template": "/articles/{}", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal", + "targets": [ + "GET /articles/featured" + ] + } + }, + { + "id": "match/param-to-literal-two", + "ruleId": "match.param-to-literal", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/tags/new" + }, + { + "method": "GET", + "template": "/tags/top" + } + ], + "call": { + "method": "GET", + "template": "/tags/{}", + "pathAnchor": "root" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /tags/new", + "GET /tags/top" + ] + } + }, + { + "id": "match/empty-segment-not-param", + "ruleId": "match.param", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a/{}" + } + ], + "call": { + "method": "GET", + "template": "/a/", + "pathAnchor": "root" + } + }, + "expect": { + "status": "missing" + } + }, + { + "id": "match/specificity-left-first", + "ruleId": "match.specificity", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/{}" + }, + { + "method": "GET", + "template": "/{}/me" + } + ], + "call": { + "method": "GET", + "template": "/users/me", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/{}" + ] + } + }, + { + "id": "match/specificity-tie", + "ruleId": "match.specificity", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a{}" + }, + { + "method": "GET", + "template": "/{}b" + } + ], + "call": { + "method": "GET", + "template": "/axb", + "pathAnchor": "root" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /a{}", + "GET /{}b" + ] + } + }, + { + "id": "match/partial-segment", + "ruleId": "match.partial", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{}.json" + } + ], + "call": { + "method": "GET", + "template": "/files/a.json", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal", + "targets": [ + "GET /files/{}.json" + ] + } + }, + { + "id": "match/partial-needs-middle", + "ruleId": "match.partial", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{}.json" + } + ], + "call": { + "method": "GET", + "template": "/files/.json", + "pathAnchor": "root" + } + }, + "expect": { + "status": "missing" + } + }, + { + "id": "match/catch-all-one-or-more", + "ruleId": "match.catch-all", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{**}" + } + ], + "call": { + "method": "GET", + "template": "/files/a/b", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "catch-all", + "targets": [ + "GET /files/{**}" + ] + } + }, + { + "id": "match/catch-all-zero-segments-needs-prefix", + "ruleId": "match.catch-all", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{**}" + } + ], + "call": { + "method": "GET", + "template": "/files", + "pathAnchor": "root" + } + }, + "expect": { + "status": "near-miss", + "reason": "trailing-slash" + }, + "note": "Without the expanded catchAllPrefix decl, /files does not match /files/{**}. Adding the trailing slash reaches {**} only through an unproven empty tail, so the result is a trailing-slash warning, never route-call-without-decl." + }, + { + "id": "match/catch-all-prefix", + "ruleId": "match.catch-all", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{**}", + "usr": "h1" + }, + { + "method": "GET", + "template": "/files", + "usr": "h1", + "catchAllPrefix": true + } + ], + "call": { + "method": "GET", + "template": "/files", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "catch-all", + "targets": [ + "GET /files" + ] + } + }, + { + "id": "match/explicit-beats-catch-all-prefix", + "ruleId": "match.catch-all", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{**}", + "usr": "h1" + }, + { + "method": "GET", + "template": "/files", + "usr": "h1", + "catchAllPrefix": true + }, + { + "method": "GET", + "template": "/files", + "usr": "h2" + } + ], + "call": { + "method": "GET", + "template": "/files", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /files" + ], + "catchAllPrefixTargets": 0 + } + }, + { + "id": "match/catch-all-empty-tail-unproven", + "ruleId": "match.catch-all", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/files/{**}" + } + ], + "call": { + "method": "GET", + "template": "/files/", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal", + "targets": [ + "GET /files/{**}" + ] + } + }, + { + "id": "match/constraint-int-excludes", + "ruleId": "match.constraints", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/{}", + "paramConstraints": [ + { + "segment": 1, + "kind": "int" + } + ] + } + ], + "call": { + "method": "GET", + "template": "/users/me", + "pathAnchor": "root" + } + }, + "expect": { + "status": "missing" + } + }, + { + "id": "match/constraint-int-signed", + "ruleId": "match.constraints", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/{}", + "paramConstraints": [ + { + "segment": 1, + "kind": "int" + } + ] + } + ], + "call": { + "method": "GET", + "template": "/users/-1", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/{}" + ] + } + }, + { + "id": "match/constraint-percent-unevaluated", + "ruleId": "match.constraints", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/users/{}", + "paramConstraints": [ + { + "segment": 1, + "kind": "int" + } + ] + } + ], + "call": { + "method": "GET", + "template": "/users/%20", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /users/{}" + ] + } + }, + { + "id": "match/constraint-regex-unproven", + "ruleId": "match.constraints", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/orders/{}", + "paramConstraints": [ + { + "segment": 1, + "kind": "regex", + "pattern": "[0-9]+" + } + ] + } + ], + "call": { + "method": "GET", + "template": "/orders/abc", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal-constrained", + "targets": [ + "GET /orders/{}" + ] + } + }, + { + "id": "match/constraint-only-difference", + "ruleId": "match.constraints", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/v/{}", + "paramConstraints": [ + { + "segment": 1, + "kind": "uuid" + } + ] + }, + { + "method": "GET", + "template": "/v/{}" + } + ], + "call": { + "method": "GET", + "template": "/v/550e8400-e29b-41d4-a716-446655440000", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /v/{}", + "GET /v/{}" + ] + } + }, + { + "id": "match/method-any", + "ruleId": "match.method", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "ANY", + "template": "/b" + } + ], + "call": { + "method": "DELETE", + "template": "/b", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "any-method", + "targets": [ + "ANY /b" + ] + } + }, + { + "id": "match/method-head-as-get", + "ruleId": "match.method", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a" + } + ], + "call": { + "method": "HEAD", + "template": "/a", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "head-as-get", + "targets": [ + "GET /a" + ] + } + }, + { + "id": "match/method-options-any", + "ruleId": "match.method", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "POST", + "template": "/c" + } + ], + "call": { + "method": "OPTIONS", + "template": "/c", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "options-any", + "targets": [ + "POST /c" + ] + } + }, + { + "id": "match/method-dynamic-path-only", + "ruleId": "match.method", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "POST", + "template": "/c" + } + ], + "call": { + "template": "/c", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "POST /c" + ] + } + }, + { + "id": "match/method-mismatch", + "ruleId": "match.method", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a" + } + ], + "call": { + "method": "PUT", + "template": "/a", + "pathAnchor": "root" + } + }, + "expect": { + "status": "method-mismatch", + "provable": true + } + }, + { + "id": "match/method-filter-first", + "ruleId": "match.method", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "POST", + "template": "/users/{}" + }, + { + "method": "GET", + "template": "/users/me" + } + ], + "call": { + "method": "GET", + "template": "/users/{}", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "param-to-literal", + "targets": [ + "GET /users/me" + ] + } + }, + { + "id": "match/trailing-slash-unknown", + "ruleId": "match.trailing-slash", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a" + } + ], + "call": { + "method": "GET", + "template": "/a/", + "pathAnchor": "root" + } + }, + "expect": { + "status": "near-miss", + "reason": "trailing-slash" + } + }, + { + "id": "match/trailing-slash-optional", + "ruleId": "match.trailing-slash", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/a", + "trailingSlash": "optional" + } + ], + "call": { + "method": "GET", + "template": "/a/", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /a" + ] + } + }, + { + "id": "match/case-mismatch", + "ruleId": "match.case", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/Items" + } + ], + "call": { + "method": "GET", + "template": "/items", + "pathAnchor": "root" + } + }, + "expect": { + "status": "near-miss", + "reason": "case" + } + }, + { + "id": "match/case-insensitive", + "ruleId": "match.case", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/Items", + "caseInsensitive": true + } + ], + "call": { + "method": "GET", + "template": "/items", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /Items" + ] + } + }, + { + "id": "match/anchor-base-call-suffix", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/v1/items/{}" + } + ], + "call": { + "method": "GET", + "template": "/v1/items/{}", + "pathAnchor": "base" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /api/v1/items/{}" + ] + } + }, + { + "id": "match/anchor-base-call-no-specificity", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/v1/items/{}" + }, + { + "method": "GET", + "template": "/admin/items/{}" + } + ], + "call": { + "method": "GET", + "template": "/items/{}", + "pathAnchor": "base" + } + }, + "expect": { + "status": "ambiguous", + "targets": [ + "GET /admin/items/{}", + "GET /api/v1/items/{}" + ] + }, + "note": "Both suffix candidates rank equally on the call's segments, so the call stays ambiguous. Specificity does apply to suffix candidates within one scope and declaration side." + }, + { + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "id": "match/anchor-base-call-literal-beats-param", + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/v1/companies/tech" + }, + { + "method": "GET", + "template": "/api/v1/companies/{}" + } + ], + "call": { + "method": "GET", + "template": "/companies/tech", + "pathAnchor": "base" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /api/v1/companies/tech" + ] + } + }, + { + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "id": "match/anchor-base-call-literal-to-param-sibling", + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/v1/companies/tech" + }, + { + "method": "GET", + "template": "/api/v1/companies/{}" + } + ], + "call": { + "method": "GET", + "template": "/companies/42", + "pathAnchor": "base" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /api/v1/companies/{}" + ] + } + }, + { + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "id": "match/anchor-root-call-base-decl-literal-beats-param", + "input": { + "decls": [ + { + "method": "GET", + "template": "/companies/{}", + "pathAnchor": "base" + }, + { + "method": "GET", + "template": "/companies/tech", + "pathAnchor": "base" + } + ], + "call": { + "method": "GET", + "template": "/api/companies/tech", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /companies/tech" + ] + } + }, + { + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "id": "match/anchor-root-call-base-decl-aligned-on-call", + "input": { + "decls": [ + { + "method": "GET", + "template": "/tech", + "pathAnchor": "base" + }, + { + "method": "GET", + "template": "/{}/tech", + "pathAnchor": "base" + } + ], + "call": { + "method": "GET", + "template": "/api/companies/tech", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /{}/tech" + ] + }, + "note": "Suffix ranks are compared on the call's segments; call segments covered by the unknown base rank below every template segment." + }, + { + "id": "match/anchor-base-call-needs-literal", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/{}" + } + ], + "call": { + "method": "GET", + "template": "/{}", + "pathAnchor": "base" + } + }, + "expect": { + "status": "missing" + } + }, + { + "id": "match/anchor-root-call-base-decl", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/reports/{}", + "pathAnchor": "base" + } + ], + "call": { + "method": "GET", + "template": "/api/reports/7", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "suffix", + "targets": [ + "GET /reports/{}" + ] + } + }, + { + "id": "match/anchor-root-call-base-decl-method", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "POST", + "template": "/exports", + "pathAnchor": "base" + } + ], + "call": { + "method": "GET", + "template": "/svc/exports", + "pathAnchor": "root" + } + }, + "expect": { + "status": "method-mismatch", + "provable": false + } + }, + { + "id": "match/anchor-root-exact-wins", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/api/reports/{}" + }, + { + "method": "GET", + "template": "/reports/{}", + "pathAnchor": "base" + } + ], + "call": { + "method": "GET", + "template": "/api/reports/7", + "pathAnchor": "root" + } + }, + "expect": { + "status": "matched", + "quality": "exact", + "targets": [ + "GET /api/reports/{}" + ] + } + }, + { + "id": "match/anchor-base-base-never", + "ruleId": "match.anchor", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#조인-규칙-http", + "appliesTo": [ + "consumer" + ], + "input": { + "decls": [ + { + "method": "GET", + "template": "/reports/{}", + "pathAnchor": "base" + } + ], + "call": { + "method": "GET", + "template": "/reports/7", + "pathAnchor": "base" + } + }, + "expect": { + "status": "missing" + } + }, + { + "id": "spring/double-wildcard-zero-segments", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardTheRestPathElement.java#L35", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "classMapping": "/files", + "mapping": "/**" + }, + "expect": { + "templates": [ + "/files", + "/files/{**}" + ], + "catchAllPrefixTemplates": [ + "/files" + ], + "trailingSlash": "strict" + }, + "note": "Verified at v6.2.10: the parser folds the separator before a trailing ** into WildcardTheRestPathElement (InternalPathPatternParser#peekDoubleWildcard), which matches when no path data remains, so /files/** also matches /files (PathPattern javadoc L44: \"** matches zero or more path segments until the end of the path\"). Emit the {**} decl plus the catchAllPrefix decl. Combined class and method mappings use PathPattern#combine/concat." + }, + { + "id": "spring/capture-rest-zero-segments", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureTheRestPathElement.java#L57", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/files/{*path}" + }, + "expect": { + "templates": [ + "/files", + "/files/{**}" + ], + "catchAllPrefixTemplates": [ + "/files" + ], + "trailingSlash": "strict" + }, + "note": "Verified at v6.2.10: CaptureTheRestPathElement replaces the preceding separator and matches an empty remainder; PathPattern javadoc L68-L69 says /resources/{*path} matches /resources as well." + }, + { + "id": "spring/root-double-wildcard", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardTheRestPathElement.java#L35", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/**" + }, + "expect": { + "templates": [ + "/", + "/{**}" + ], + "catchAllPrefixTemplates": [ + "/" + ], + "trailingSlash": "strict" + }, + "note": "The prefix decl of a root catch-all is \"/\" (GRAPH-EXCHANGE 0-segment catch-all rule); verified at v6.2.10 that /** matches the root path." + }, + { + "id": "spring/middle-double-wildcard-rejected", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java#L251", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/files/**/meta" + }, + "expectDynamic": true, + "expectLimitation": "route-coverage:", + "note": "PathPatternParser rejects ** followed by more pattern (NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST, verified at v6.2.10). Such a mapping only exists with AntPathMatcher configuration, which this producer does not model: emit dynamic plus route-coverage:." + }, + { + "id": "spring/trailing-slash-strict-default", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/PathPatternParser.java#L38", + "versionRange": ">=6.0 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/items" + }, + "expect": { + "templates": [ + "/items" + ], + "trailingSlash": "strict" + }, + "note": "matchOptionalTrailingSeparator defaults to false; the javadoc (L52) says the default changed in 6.0 from true to false. Spring 5.x defaults are out of this range." + }, + { + "id": "spring/trailing-slash-optional-when-enabled", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/PathPatternParser.java#L45", + "versionRange": ">=6.0 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/items", + "matchOptionalTrailingSeparator": true + }, + "expect": { + "templates": [ + "/items" + ], + "trailingSlash": "optional" + }, + "note": "Only when the application explicitly calls the deprecated setMatchOptionalTrailingSeparator(true) (verified at v6.2.10) does /items also match /items/. Emit optional only when that call is proven for the parser in use; otherwise strict." + }, + { + "id": "spring/empty-method-mapping-keeps-class-path", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/PathPatternsRequestCondition.java#L164", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "classMapping": "/api", + "mapping": "" + }, + "expect": { + "templates": [ + "/api" + ], + "trailingSlash": "strict" + }, + "note": "An empty method mapping is the empty path mapping (L78: no text), and combine returns the class condition unchanged (L164), so the handler serves /api, not /api/ (verified at v6.2.10)." + }, + { + "id": "spring/slash-method-mapping-adds-trailing-slash", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java#L561", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "classMapping": "/api", + "mapping": "/" + }, + "expect": { + "templates": [ + "/api/" + ], + "trailingSlash": "strict" + }, + "note": "PathPattern#combine concatenates /api and / into /api/ (concat, verified at v6.2.10). With the strict 6.x default a call to /api is a trailing-slash mismatch, not a match." + }, + { + "id": "spring/empty-class-and-method-mapping-is-root", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/PathPatternsRequestCondition.java#L162", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "classMapping": "", + "mapping": "" + }, + "expect": { + "templates": [ + "/" + ], + "trailingSlash": "strict" + }, + "note": "When both class and method mappings are empty, combine uses the root patterns \"\" and \"/\" (L156-L162, verified at v6.2.10). The canonical grammar has no empty template, so producers emit \"/\" only." + }, + { + "id": "spring/trailing-wildcard-matches-empty", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardPathElement.java#L26", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/files/*" + }, + "expect": { + "templates": [ + "/files/", + "/files/{}" + ], + "trailingSlash": "strict" + }, + "note": "A * that is the last pattern element matches zero characters (class javadoc L25-L26 and matches(), verified at v6.2.10), so /files/* serves /files/. Emit the {} decl plus an empty-value variant decl /files/ (same method, symbol, and location); consumer {} never matches an empty segment." + }, + { + "id": "spring/middle-wildcard-needs-a-character", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardPathElement.java#L76", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/files/*/meta" + }, + "expect": { + "templates": [ + "/files/{}/meta" + ], + "trailingSlash": "strict" + }, + "note": "Within a path a * must match at least one character (L76, verified at v6.2.10); no empty-value variant." + }, + { + "id": "spring/whole-segment-variable-needs-a-character", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureVariablePathElement.java#L76", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/users/{id}" + }, + "expect": { + "templates": [ + "/users/{}" + ], + "trailingSlash": "strict" + }, + "note": "A whole-segment variable rejects an empty capture (L76, verified at v6.2.10); no empty-value variant." + }, + { + "id": "spring/partial-variable-allows-empty", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/RegexPathElement.java#L42", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/files/{name}.json" + }, + "expect": { + "templates": [ + "/files/.json", + "/files/{}.json" + ], + "trailingSlash": "strict" + }, + "note": "A segment mixing literals and a variable becomes RegexPathElement whose default variable pattern is (.*) (L42), so the capture may be empty; the last-element check (L143) only requires the whole segment text to be non-empty (verified at v6.2.10). Consumer partial matching requires a non-empty middle, so emit the empty-value variant decl /files/.json as well." + }, + { + "id": "spring/partial-wildcard-allows-empty", + "ruleId": "framework.spring.path-pattern", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.10/spring-web/src/main/java/org/springframework/web/util/pattern/RegexPathElement.java#L77", + "versionRange": ">=6.2 <7.0", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/files/*.json" + }, + "expect": { + "templates": [ + "/files/.json", + "/files/{}.json" + ], + "trailingSlash": "strict" + }, + "note": "A * inside a segment becomes .* in RegexPathElement (verified at v6.2.10) and may be empty; same expansion as a partial variable." + }, + { + "id": "spring/empty-value-expansion-capped", + "ruleId": "framework.spring.path-pattern", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#정규-경로-템플릿", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "framework": "spring-mvc", + "mapping": "/{a}.x/{b}.x/{c}.x/{d}.x/{e}.x" + }, + "expectDynamic": true, + "expectLimitation": "route-template-expansion-capped:", + "note": "Empty-value variants follow the optional-segment expansion limit: more than 16 decls are emitted as dynamic plus route-template-expansion-capped:." + } + ] +} diff --git a/conformance/url-compose.json b/conformance/url-compose.json new file mode 100644 index 0000000..3173e31 --- /dev/null +++ b/conformance/url-compose.json @@ -0,0 +1,1201 @@ +{ + "format": "isthmus-conformance", + "version": 1, + "suite": "url-compose", + "description": "Shared producer rules for turning client URL expressions and declared HTTP wrapper calls into route-call templates: interpolation, query tails, base joins, stripping, masking, and wrapper argument binding.", + "contract": "docs/HTTP-WRAPPERS.md", + "cases": [ + { + "id": "interpolation/whole-segment", + "ruleId": "compose.interpolation", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/api/v1/items/" + }, + { + "value": "id" + } + ] + }, + "expect": { + "template": "/api/v1/items/{}" + } + }, + { + "id": "interpolation/middle-segment", + "ruleId": "compose.interpolation", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/users/" + }, + { + "value": "user" + }, + { + "literal": "/items/" + }, + { + "value": "item" + } + ] + }, + "expect": { + "template": "/users/{}/items/{}" + } + }, + { + "id": "interpolation/partial-segment-is-dynamic", + "ruleId": "compose.interpolation", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/files/" + }, + { + "value": "name" + }, + { + "literal": ".json" + } + ] + }, + "expect": { + "channelPrefix": "/files/" + }, + "expectDynamic": true, + "note": "Only an interpolation that fills a whole segment becomes {}. Partial call segments are dynamic even though server templates may use p{}s." + }, + { + "id": "interpolation/glued-to-literal-is-dynamic", + "ruleId": "compose.interpolation", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/items" + }, + { + "value": "suffix" + } + ] + }, + "expect": { + "channelPrefix": "/items" + }, + "expectDynamic": true + }, + { + "id": "interpolation/two-in-segment", + "ruleId": "compose.interpolation", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/v" + }, + { + "value": "major" + }, + { + "literal": "." + }, + { + "value": "minor" + } + ] + }, + "expect": { + "channelPrefix": "/v" + }, + "expectDynamic": true + }, + { + "id": "interpolation/leading-value-is-base", + "ruleId": "compose.interpolation", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "value": "baseUrl" + }, + { + "literal": "/items" + } + ] + }, + "expectDynamic": true, + "note": "A value before the first literal slash is a base expression; apply compose.base-join instead of this rule." + }, + { + "id": "query-tail/literal-question-mark", + "ruleId": "compose.query-tail", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/items?page=" + }, + { + "value": "page" + } + ] + }, + "expect": { + "template": "/items", + "queryTailStripped": true + } + }, + { + "id": "query-tail/first-question-mark", + "ruleId": "compose.query-tail", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/search?q=" + }, + { + "value": "q" + }, + { + "literal": "&next=/x?y" + } + ] + }, + "expect": { + "template": "/search", + "queryTailStripped": true + } + }, + { + "id": "query-tail/after-path-value", + "ruleId": "compose.query-tail", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/items/" + }, + { + "value": "id" + }, + { + "literal": "?expand=true" + } + ] + }, + "expect": { + "template": "/items/{}", + "queryTailStripped": true + } + }, + { + "id": "query-tail/fragment", + "ruleId": "compose.query-tail", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/docs#section" + } + ] + }, + "expect": { + "template": "/docs", + "queryTailStripped": true + } + }, + { + "id": "suffix/proven-query-local", + "ruleId": "compose.suffix", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/api/v1/articles" + }, + { + "queryTail": "suffix" + } + ] + }, + "expect": { + "template": "/api/v1/articles", + "queryTailStripped": true + }, + "note": "A trailing local whose initializer provably starts with '?' or is empty/orEmpty on every other branch is a query tail." + }, + { + "id": "suffix/unproven-local", + "ruleId": "compose.suffix", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/api/v1/articles" + }, + { + "value": "suffix" + } + ] + }, + "expect": { + "channelPrefix": "/api/v1/articles" + }, + "expectDynamic": true + }, + { + "id": "suffix/query-local-not-last", + "ruleId": "compose.suffix", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/a" + }, + { + "queryTail": "q" + }, + { + "literal": "/b" + } + ] + }, + "expect": { + "channelPrefix": "/a" + }, + "expectDynamic": true + }, + { + "id": "normalize/non-ascii-literal", + "ruleId": "compose.normalize", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/café/" + }, + { + "value": "id" + } + ] + }, + "expect": { + "template": "/caf%C3%A9/{}" + } + }, + { + "id": "normalize/literal-brace", + "ruleId": "compose.normalize", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "parts": [ + { + "literal": "/tpl/{x}" + } + ] + }, + "expect": { + "template": "/tpl/%7Bx%7D" + } + }, + { + "id": "base-join/rfc3986-absolute-path", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "/items" + }, + "expect": { + "template": "/items", + "pathAnchor": "root" + } + }, + { + "id": "base-join/rfc3986-relative-path", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "items" + }, + "expect": { + "template": "/items", + "pathAnchor": "base" + } + }, + { + "id": "base-join/slash-join-absolute-path", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "slash-join", + "base": null, + "path": "/items" + }, + "expect": { + "template": "/items", + "pathAnchor": "base" + } + }, + { + "id": "base-join/slash-join-relative-path", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "slash-join", + "base": null, + "path": "items" + }, + "expect": { + "template": "/items", + "pathAnchor": "base" + } + }, + { + "id": "base-join/dio-literal-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "dio-concat", + "base": "https://api.example.com/v1", + "path": "/items" + }, + "expect": { + "template": "/v1/items", + "pathAnchor": "root" + } + }, + { + "id": "base-join/dio-unknown-base-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "dio-concat", + "base": null, + "path": "/items" + }, + "expect": { + "template": "/items", + "pathAnchor": "base" + } + }, + { + "id": "base-join/dio-unknown-base-relative", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "join": "dio-concat", + "base": null, + "path": "items" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:" + }, + { + "id": "base-join/spring-literal-base-rooted", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": "http://users.internal:8081/api", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "users.internal:8081" + }, + "note": "DefaultUriBuilderFactory copies the base builder and appends the template path as a plain string (FullPathComponentBuilder.append)." + }, + { + "id": "base-join/spring-literal-base-double-slash", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": "http://users.internal:8081/api/", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "The concatenation is /api//users; getSanitizedPath collapses every // in the path (not only after the scheme)." + }, + { + "id": "base-join/spring-literal-base-relative", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": "http://users.internal:8081/api", + "path": "users" + }, + "expect": { + "template": "/apiusers", + "pathAnchor": "root" + }, + "note": "No slash is inserted between the base path and a relative template." + }, + { + "id": "base-join/spring-host-only-base-relative", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": "http://users.internal:8081", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "With no base path the URI string inserts / after the authority, so the relative template is not glued to the host (unlike dio). Confirmed by the kartograph Spring client oracle." + }, + { + "id": "base-join/spring-keeps-dot-segments", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/util/DefaultUriBuilderFactory.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": "http://users.internal:8081/api", + "path": "/users/./me/../self" + }, + "expect": { + "template": "/api/users/./me/../self", + "pathAnchor": "root" + }, + "note": "DefaultUriBuilderFactory never calls normalize(); the kartograph oracle recorded the request path with the dot segments intact." + }, + { + "id": "base-join/spring-unknown-base-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#base-결합", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": null, + "path": "/users//42" + }, + "expect": { + "template": "/users/42", + "pathAnchor": "base" + }, + "note": "Unknown base: a rooted template is a base-anchored suffix; // collapses in the template too." + }, + { + "id": "base-join/spring-unknown-base-relative", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#base-결합", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-uri-builder", + "base": null, + "path": "users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "Without the base path the plain concatenation cannot be resolved (like dio)." + }, + { + "id": "base-join/spring-root-uri-literal-rooted", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-boot/blob/v3.5.16/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RootUriTemplateHandler.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-root-uri", + "base": "http://legacy.internal:8080/v1", + "path": "/orders" + }, + "expect": { + "template": "/v1/orders", + "pathAnchor": "root", + "authority": "legacy.internal:8080" + }, + "note": "RestTemplateBuilder.rootUri prepends the root only to templates starting with /; the result is parsed without a base (// collapses)." + }, + { + "id": "base-join/spring-root-uri-relative", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-boot/blob/v3.5.16/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RootUriTemplateHandler.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-root-uri", + "base": "http://legacy.internal:8080/v1", + "path": "orders" + }, + "expectDynamic": true, + "note": "A template not starting with / gets no root and has no host, so no request path can be claimed." + }, + { + "id": "base-join/spring-root-uri-unknown-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#base-결합", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-root-uri", + "base": null, + "path": "/orders" + }, + "expect": { + "template": "/orders", + "pathAnchor": "base" + } + }, + { + "id": "base-join/spring-http-exchange-slash-inserted", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-http-exchange", + "base": "http://users.internal:8081/svc", + "typeUrl": "/api", + "path": "users" + }, + "expect": { + "template": "/svc/api/users", + "pathAnchor": "root" + }, + "note": "HttpServiceMethod.initUrl inserts / only when the type url does not end with / and the method url does not start with /; the result then joins the client base like spring-uri-builder." + }, + { + "id": "base-join/spring-http-exchange-double-slash", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-http-exchange", + "base": null, + "typeUrl": "/api/", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "base" + } + }, + { + "id": "base-join/spring-http-exchange-relative", + "ruleId": "compose.base-join", + "provenance": "verified-source", + "source": "https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java", + "appliesTo": [ + "producer:kartograph" + ], + "input": { + "join": "spring-http-exchange", + "base": null, + "typeUrl": "api", + "path": "users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:" + }, + { + "id": "strip/userinfo-query-fragment", + "ruleId": "compose.strip", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "url": "https://user:secret@API.Example.com/v1/items?token=abc#top" + }, + "expect": { + "template": "/v1/items", + "authority": "api.example.com", + "queryTailStripped": true + } + }, + { + "id": "strip/port-kept", + "ruleId": "compose.strip", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "url": "http://api.example.com:8080/v1" + }, + "expect": { + "template": "/v1", + "authority": "api.example.com:8080" + } + }, + { + "id": "strip/root", + "ruleId": "compose.strip", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "url": "https://api.example.com" + }, + "expect": { + "template": "/", + "authority": "api.example.com" + } + }, + { + "id": "mask/high-entropy-id", + "ruleId": "compose.mask", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "authority": "api.example.com", + "template": "/v1/items/550e8400-e29b-41d4-a716-446655440000" + }, + "expect": { + "template": "/v1/items/{}", + "maskedSegments": 1 + } + }, + { + "id": "mask/long-word-kept", + "ruleId": "compose.mask", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "authority": "api.example.com", + "template": "/internationalization/settings" + }, + "expect": { + "template": "/internationalization/settings", + "maskedSegments": 0 + } + }, + { + "id": "mask/short-token-kept", + "ruleId": "compose.mask", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "authority": "api.example.com", + "template": "/v2/abc123" + }, + "expect": { + "template": "/v2/abc123", + "maskedSegments": 0 + } + }, + { + "id": "mask/slack-webhook", + "ruleId": "compose.mask", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "authority": "hooks.slack.com", + "template": "/services/T0000/B0000/XXXXXXXXXXXXXXXX" + }, + "expect": { + "template": "/{}/{}/{}/{}", + "maskedSegments": 4 + } + }, + { + "id": "mask/discord-webhook", + "ruleId": "compose.mask", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "authority": "discord.com", + "template": "/api/webhooks/123/abcDEF" + }, + "expect": { + "template": "/api/webhooks/{}/{}", + "maskedSegments": 2 + } + }, + { + "id": "mask/param-segments-untouched", + "ruleId": "compose.mask", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "authority": "api.example.com", + "template": "/v1/{}/a1b2c3d4e5f6a7b8c9" + }, + "expect": { + "template": "/v1/{}/{}", + "maskedSegments": 1 + } + }, + { + "id": "method/enum-case", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "label": "method" + }, + "methodEnum": { + "get": "GET", + "post": "POST" + } + }, + "call": { + "args": [ + { + "label": "method", + "value": { + "enumCase": "get" + } + }, + { + "label": "path", + "value": { + "literal": "/x" + } + } + ] + } + }, + "expect": { + "method": "GET" + } + }, + { + "id": "method/enum-constant-name", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "index": 0 + }, + "methodEnum": { + "GET": "GET", + "DELETE": "DELETE" + } + }, + "call": { + "args": [ + { + "value": { + "enumCase": "DELETE" + } + }, + { + "value": { + "literal": "/x" + } + } + ] + } + }, + "expect": { + "method": "DELETE" + } + }, + { + "id": "method/enum-without-verb", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "label": "method" + }, + "methodEnum": { + "get": "GET" + } + }, + "call": { + "args": [ + { + "label": "method", + "value": { + "enumCase": "custom" + } + } + ] + } + }, + "expectDynamic": true + }, + { + "id": "method/default-argument", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "index": 2, + "label": "method" + }, + "defaultMethod": "POST" + }, + "call": { + "args": [ + { + "value": { + "literal": "/x" + } + }, + { + "value": { + "value": "token" + } + } + ] + } + }, + "expect": { + "method": "POST" + } + }, + { + "id": "method/named-out-of-order", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "index": 2, + "label": "method" + }, + "defaultMethod": "POST" + }, + "call": { + "args": [ + { + "label": "path", + "value": { + "literal": "/x" + } + }, + { + "label": "auth", + "value": { + "value": "t" + } + }, + { + "label": "method", + "value": { + "literal": "GET" + } + } + ] + } + }, + "expect": { + "method": "GET" + } + }, + { + "id": "method/positional", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "index": 0 + } + }, + "call": { + "args": [ + { + "value": { + "literal": "PATCH" + } + }, + { + "value": { + "literal": "/x" + } + } + ] + } + }, + "expect": { + "method": "PATCH" + } + }, + { + "id": "method/non-literal-argument", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "index": 0 + }, + "defaultMethod": "GET" + }, + "call": { + "args": [ + { + "value": { + "value": "verb" + } + } + ] + } + }, + "expectDynamic": true, + "note": "A present but non-literal argument is methodDynamic; the default applies only when the argument is omitted." + }, + { + "id": "method/no-argument-no-default", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "label": "method" + } + }, + "call": { + "args": [ + { + "label": "path", + "value": { + "literal": "/x" + } + } + ] + } + }, + "expectDynamic": true + }, + { + "id": "method/lowercase-literal-not-a-verb", + "ruleId": "wrapper.method", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "declaration": { + "methodArg": { + "index": 0 + } + }, + "call": { + "args": [ + { + "value": { + "literal": "get" + } + } + ] + } + }, + "expectDynamic": true, + "note": "Only the exact upper-case verb literal is a verb; map other spellings through methodEnum." + }, + { + "id": "location/multi-line-call", + "ruleId": "wrapper.location", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#공통-해석-규칙", + "appliesTo": [ + "producer" + ], + "input": { + "callStartLine": 89, + "methodArgLine": 90, + "pathArgLine": 91 + }, + "expect": { + "line": 89 + } + } + ] +} diff --git a/docs/HTTP-ROUTES.md b/docs/HTTP-ROUTES.md new file mode 100644 index 0000000..037fead --- /dev/null +++ b/docs/HTTP-ROUTES.md @@ -0,0 +1,199 @@ +# HTTP 라우트 선언 규칙 (`rustograph routes --role server`) + +rustograph가 axum·actix-web 프로젝트에서 isthmus http `route-decl` 사실을 만드는 규칙과, 각 규칙을 확인한 공식 +소스를 적는다. 규칙은 추정하지 않고 아래 버전의 crates.io 소스를 직접 읽어 확인했고(2026-09-30), 경계가 +애매한 규칙은 합성 fixture를 진짜 크레이트로 컴파일해 요청을 보내는 오라클로 실측했다(아래 "오라클 검증"). +소스 경로는 각 크레이트 루트 기준이다. + +| 크레이트 | 확인한 버전 | rustograph가 받는 선언 범위 | +|---|---|---| +| axum | 0.7.9 (matchit 0.7.3), 0.8.9 (matchit 0.8.4) | 0.7.x·0.8.x | +| actix-web | 4.15.0 (actix-router 0.5.4, actix-web-codegen 4.4.0) | 4.x | +| tower-http | 0.6.11 (`NormalizePathLayer`) | 0.6.x | + +버전은 `cargo metadata`의 resolve가 고정한 패키지 버전(= Cargo.lock)으로 멤버 크레이트마다 고른다. 확인한 메이저 +밖이면 가까운 문법(axum 0.6 → 0.7 문법, 그 밖 → 0.8 문법, actix 4 밖 → 4 규칙)으로 읽고 +`route-framework-version-unknown:` 한계를 낸다. 분석 대상은 syn으로만 읽는다 — 컴파일·실행·네트워크 접근이 없다. +워크스페이스에 두 프레임워크가 모두 있으면 추측하지 않고 `--framework axum|actix`를 요구한다(문서 하나는 +dispatch 하나만 선언한다). 둘 다 없으면 사실 0건과 `route-coverage:`를 낸다. + +## 심볼 id + +`symbol.usr`(와 `qualifiedName`)는 `impact`·`reach`와 같은 rustograph 정점 ID다 — 같은 syn 수확의 모듈 트리로 +핸들러 경로를 해석하고 그래프 정점 집합으로 확인한다. `reach --roots-from routes.json`이 그대로 root로 받는다 +(verify-cli-contract가 확인한다). + +| 핸들러 | id 예시 | +|---|---| +| 모듈 수준 함수 | `axum_app::handlers::items::list` | +| 연관 함수 `Api::list` | `app::Api::list` (트레이트 impl이면 `Type::::m` 중 유일한 것) | +| actix 매크로 핸들러(`#[get]`) | 그 함수 — `actix_app::handlers::index` | +| 클로저 `get(\|\| async {..})` | 감싸는 함수(예: `axum_app::routes::api`) + `missing-route-usrs:` | +| tower 서비스·리다이렉트·해석 못 한 경로 | usr 없음 + `missing-route-usrs:` | + +## axum + +| 규칙 | 확인한 소스 | +|---|---| +| 0.7: `:`·`*`는 세그먼트 어디서든 와일드카드를 시작해 다음 `/`까지 간다. 앞 글자는 정적 접두사(`/user_:id`), 뒤 글자는 이름에 흡수된다(`/:id.json`은 이름 `id.json`인 세그먼트 전체 파라미터). 한 세그먼트에 둘은 `TooManyParams` | matchit 0.7.3 `src/tree.rs:651-670`, `:661` | +| 0.7: `*rest`는 `/` 바로 뒤·경로 끝에만 오고 빈 나머지와 맞지 않는다(`/files/*p`는 `/files`·`/files/` 불일치). `{`·`}`는 리터럴 | `tree.rs:266-275`, `:350`, `:482-499`; axum 0.7.9 `src/docs/routing/route.md:53-56` | +| 0.8: `{name}`은 앞에 정적 글자를 둘 수 있지만 뒤에는 못 둔다(`/{id}.json`은 삽입 오류). `{*name}`은 끝에만, `{{`·`}}`는 리터럴 중괄호 | matchit 0.8.4 `src/tree.rs:752-803`(`:783-788`), `src/error.rs:15-19`, `src/escape.rs:14-31`, `tree.rs:372-374` | +| 0.8: `{*rest}`도 빈 나머지와 맞지 않는다 | `tree.rs:491-507`, `:510`, `:614-632`; axum 0.8.9 `src/docs/routing/route.md:53-56` | +| 0.8: `:`·`*`로 **시작하는** 세그먼트는 기동 시 panic(`without_v07_checks`로 끌 수 있음). 세그먼트 중간의 `:`는 리터럴 | axum 0.8.9 `src/routing/path_router.rs:53-73`, `src/routing/mod.rs:170-174` | +| 우선순위: 정적 > 파라미터 > catch-all, 되돌아가기 있음(`/a/b/c`와 `/:x/b/d`에서 `/a/b/d`는 뒤쪽). 충돌 경로는 삽입 시 panic | 0.7.3 `tree.rs:164-174`, `:312-330`, `:361-384`; 0.8.4 `tree.rs:450-469`, `:526-559`; axum `route.md:154-155` | +| 끝 슬래시는 엄격하다 — `/foo`와 `/foo/`는 다른 경로이고 리다이렉트가 없다(불일치는 fallback으로) | axum 0.7.9 `path_router.rs:381-385`; matchit 0.8.4 `error.rs:116-119`, axum 0.8.9 `path_router.rs:418` | +| 경로를 찾은 뒤 method가 맞지 않으면 405이고 다른 라우트·fallback을 다시 시도하지 않는다. `get`은 HEAD도 받는다(명시 `head`가 먼저) | axum 0.7.9 `src/routing/method_routing.rs:54-56`, `:746-751`, `:1157-1159`, `path_router.rs:372-375` | +| `any(h)` = fallback이 있는 MethodRouter, `on(MethodFilter, h)`는 필터 비트마다, `MethodFilter`는 CONNECT·DELETE·GET·HEAD·OPTIONS·PATCH·POST·PUT·TRACE | `method_routing.rs:464-471`, `:506-513`, `src/routing/method_filter.rs:29-45` | +| 같은 경로의 `.route()` 두 번은 MethodRouter를 합친다(같은 method가 겹치면 panic) | 0.7.9 `path_router.rs:59-73`, `method_routing.rs:1038-1083` | +| `nest(prefix, r)`: `prefix`가 `/`로 끝나면 `prefix + path.trim_start('/')`, 안쪽이 `/`면 `prefix`, 아니면 `prefix + path`(두 버전 같음). 안쪽 `/`는 `/api`만 만든다(`/api/`는 아님) | 0.7.9 `path_router.rs:496-507`, `src/routing/tests/nest.rs:350-383`; 0.8.9 `path_router.rs:535-546`, `tests/nest.rs:368-373` | +| nest 경로 검증: 0.7은 빈 경로를 `/`로, `*` 포함 panic. 0.8은 루트 nest panic("use merge"), `{*`로 시작하는 세그먼트 panic | 0.7.9 `path_router.rs:482-494`; 0.8.9 `path_router.rs:517-533`, `mod.rs:210-212` | +| `merge`는 다른 라우터의 라우트를 같은 규칙으로 다시 넣는다 | 0.7.9 `path_router.rs:135-173` | +| Router fallback은 경로가 맞지 않은 요청만 받는다(method 불일치는 405). 안쪽 라우터에 fallback이 있으면 nest가 `{p}`·`{p}/{*..}`로 옮긴다 | `src/docs/routing/fallback.md:24-29`, 0.7.9 `mod.rs:209-211`, `:379-394` | +| `nest_service(p, svc)`는 `{p}/{*tail}`·`{p}`·`{p}/`를 등록한다. `route_service(p, svc)`는 단일 경로·모든 method | 0.7.9 `path_router.rs:213-250`, `mod.rs:177-185` | +| `Router::layer` 미들웨어는 **라우팅 뒤**에 돈다 — URI를 바꾸는 미들웨어(`NormalizePathLayer`)는 Router 바깥을 감쌀 때만 라우팅에 영향을 준다 | `src/docs/routing/layer.md:57-62`, `src/docs/middleware.md:525-530` | +| tower-http `NormalizePathLayer::trim_trailing_slash()`는 앞뒤 슬래시를 모두 떼고 `/` 하나를 붙인다(루트는 `/`). `append_trailing_slash()`는 끝에 `/` 하나 | tower-http 0.6.11 `src/normalize_path.rs:146-173`, `:175-207` | +| matchit은 **마지막이 아닌** 세그먼트의 파라미터(세그먼트 전체, 앞 글자 붙은 것 모두)가 빈 값과도 맞는다(`/items//tags/x`, `/v/status`). 마지막 세그먼트의 파라미터는 빈 값과 맞지 않는다(`/items/`, `/tag_` 불일치) | 오라클 실측(0.7.9·0.8.9) | +| matchit은 요청의 **원문(퍼센트 인코딩된) 경로**를 바이트로 비교한다 — 라우트 리터럴 `{braces}`는 `/%7Bbraces%7D` 요청과 맞지 않는다 | 오라클 실측(0.8.9) | + +### 사실로 바꾸는 방법 + +- **dispatch**: `specificity`. isthmus 구체성(리터럴 > 부분 세그먼트 > 제약 있는 `{}` > `{}` > `{**}`)은 matchit 우선순위와 + 같은 방향이다. axum은 경로를 먼저 고르고 method가 맞지 않으면 405인데 isthmus는 method를 먼저 거르므로, 차이는 + 거짓 match 쪽(정적 경로가 GET만, 파라미터 경로가 POST를 받을 때 `POST /x/static`)이지 거짓 error가 아니다. +- **라우터 평가**: 서빙되는 식(`axum::serve(_, X)`, `X.into_make_service()`)을 루트로, 같은 함수의 `let`·재대입과 + 크레이트 함수 호출(반환식)을 따라 `route`·`nest`·`merge`·`nest_service`·`route_service`·`fallback`을 정적으로 편다. + `layer`·`route_layer`·`with_state` 등은 투명하다. 루트에서 닿지 않은 라우터 함수(`Router::new()`를 부르는 함수)의 + 선언은 `pathAnchor: "base"`와 `unresolved-route-prefix:`(선언 템플릿의 `templateSuffixes` 스코프)로 낸다. +- **channel**: nest 결합을 axum과 같은 문자열 규칙으로 한 뒤 버전별 문법으로 해석한다. 파라미터는 `{}`(앞 글자가 + 있으면 `p{}`), catch-all은 `{**}`. 리터럴이 정규형과 다르면(중괄호·공백·ASCII 밖 글자·인코딩된 unreserved) + 정규 템플릿으로 보낸 요청이 닿지 않으므로 선언 대신 그 템플릿 스코프의 `route-coverage:`를 낸다. +- **빈 값 변형**: 마지막이 아닌 세그먼트의 파라미터마다 빈 값으로 채운 변형(`/items//tags/{}`, `/v/status`)을 같은 + method·symbol·location으로 함께 낸다. 16개를 넘으면 dynamic과 `route-template-expansion-capped:`다. 같은 앵커·템플릿의 + 진짜 선언이 있으면 변형은 뺀다 — 정적 경로가 먼저 골라지고 경로가 맞으면 method가 달라도 405로 끝나므로 + (`/v{x}/status` GET과 `/v/status` POST가 있으면 `GET /v/status`는 405, 오라클 실측) 변형 핸들러에 닿는 요청이 없다. +- **method**: 동사 생성자·체인은 그 동사, `any`·MethodRouter `fallback`은 `ANY`, `on`은 필터의 동사(모르는 필터는 + `ANY`). `get`의 자동 HEAD는 내지 않는다(소비자 `head-as-get`). CONNECT는 http 도메인 동사가 없어 한계다. +- **trailingSlash**: `strict`, `{**}`로 끝나면 생략. 레이어가 서비스를 감싸는 모양 + (`NormalizePathLayer::trim_trailing_slash().layer(svc)`, `ServiceBuilder::new()….layer(NormalizePathLayer::…).service(svc)`, + `NormalizePath::trim_trailing_slash(svc)`)이 있으면 trim은 끝 슬래시 없는 템플릿을, append는 끝 슬래시 있는 템플릿을 + `optional`로 낸다. `Router::layer`·`MethodRouter::layer` 인자나 변수로 넘긴 레이어처럼 라우팅 전에 동작한다는 증거가 + 없으면 효과가 없다고 본다(틀리면 선언이 strict로 남아 소비자는 끝 슬래시 불일치 경고를 낸다 — 거짓 error가 아니다). +- **location**: `.route()`의 경로 인자(줄, UTF-16 열). 경로 인자가 `&str` 상수면 그 값을 쓴다. + +## actix-web + +| 규칙 | 확인한 소스 | +|---|---| +| `{name}`은 `[^/]+`(비어 있지 않은 한 세그먼트), `{name:regex}`는 이름 붙은 그룹에 그대로 들어가 `/`를 넘을 수 있다. 전체는 `^…$`로 묶인다 | actix-router 0.5.4 `src/resource.rs:907`, `:956`, `:986`, `:1053-1062`, 문서 `:140-142` | +| 끝의 `{name}*`는 `.*`(빈 값·`/` 포함)이고 커스텀 정규식과 함께 쓰면 panic. `/files/{tail}*`는 `/files/`와 맞지만 `/files`와는 아니다 | `resource.rs:908`, `:935`, `:939`, 테스트 `:1338-1348` | +| 한 세그먼트에 정적 글자와 파라미터, 파라미터 여럿을 섞을 수 있다(최대 16) | `resource.rs` 문서 `:78-80`, `:1214`, `:16` | +| 매칭은 대소문자를 구분하고, 요청 경로는 `%25`·`%2F`·`%2B`만 남기고 디코드한 경로다 | `resource.rs:832-845`; `src/url.rs:4`, `src/quoter.rs:41-44` | +| 스코프 접두사는 세그먼트 경계로 매치해 소비하고 안쪽은 나머지로 정확 매치한다 — 결합은 문자열 연결과 같다(`/api`+`""`=`/api`, `/api`+`/`=`/api/`, `/api`+`/items`). `root_prefix`는 앞 `/`를 붙이고 안쪽은 `ensure_leading_slash` | `resource.rs:327-329`, `:503-519`; actix-web 4.15.0 `src/scope.rs:453-458`, `src/resource.rs:428-432`, `src/dev.rs:30-47`, `src/introspection.rs:576-594`, 테스트 `scope.rs:628-690` | +| App·Scope 라우터는 서비스를 등록 순서대로 시도하고 첫 매치가 이긴다(정렬·병합 없음). 리소스·스코프 수준 가드가 실패하면 다음으로 넘어간다 | `src/app_service.rs:83-85`, `:302-311`, `:332-335`; actix-router `src/router.rs:54-58`; 문서 `app.rs:213-214` | +| 리소스 안에서 맞는 라우트가 없으면 그 리소스의 기본(405)으로 끝난다 — 다음 리소스로 넘어가지 않는다. 스코프도 안에서 맞는 것이 없으면 스코프·App 기본으로 끝난다 | `src/resource.rs:562-570`, `:75-89`; `src/scope.rs:530-549`, 테스트 `:907-927` | +| `App::route`·`Scope::route`·`ServiceConfig::route`는 라우트 가드를 모두 **리소스 수준**으로 옮긴다(method 불일치는 다음 리소스로 → 404) | `src/app.rs:227-233`, `src/scope.rs:260-266`, `src/config.rs:385-391`, `src/route.rs:79-81`, 테스트 `scope.rs:694-748` | +| `#[get("/p")]` 등 매크로는 `Resource::new(p).guard(Get()).to(h)` — method 가드가 리소스 수준. `#[route(.., method=..)]`는 가드 하나에 동사 여럿, `#[routes]`는 속성마다 리소스 하나, `guard = ".."`는 리소스 가드 | actix-web-codegen 4.4.0 `src/route.rs:456-463`, `:148-197`, `:445-453`, `:509-550`, `:260-272` | +| `web::get()`…`web::trace()`는 method 가드, `web::route()`·`web::to(h)`·`Resource::to`는 모든 method, `Resource::get(h)` 등은 라우트 수준 가드, `web::redirect`는 모든 method | `src/web.rs:98-100`, `:123-129`, `:144-146`, `:162-169`; `src/resource.rs:241-249`, `:369-406`; `src/redirect.rs:150-160` | +| method 가드는 정확히 같은 동사만 받는다 — `web::get()`은 HEAD를 받지 않는다 | `src/guard/mod.rs:412`, 테스트 `src/route.rs:393-397` | +| `configure`는 호출 위치에 서비스를 순서대로 끼워 넣는다 | `src/app.rs:191-208`, `src/config.rs:374-380`, `src/scope.rs:186-206` | +| `NormalizePath::trim()`·`default()`는 Trim(`//+` 병합 + 끝 슬래시 제거, 루트는 `/`), `Always`는 끝에 `/`, `MergeOnly`는 병합만. `App::wrap`이면 라우팅 전에 동작한다 | `src/middleware/normalize.rs:44-50`, `:112-134`, `:151`, `:174-228`; `src/app.rs:367-368` | +| 정규화 미들웨어가 없으면 `/items`와 `/items/`는 서로 맞지 않는다 | actix-router `resource.rs` 문서 `:196-208` | + +### 사실로 바꾸는 방법 + +- **dispatch**: `registration-order`. `App::new()` 체인마다 group 하나(`actix:`, 한 함수에 둘 이상이면 + `#n`), 리소스 하나(매크로 속성 하나, `App::route` 하나, `web::resource` 하나)가 index 하나다. 한 리소스의 라우트·경로 + 여럿(`web::resource(["/a","/b"])`)은 같은 index·같은 위치다. 스코프·`configure` 안의 리소스는 깊이 우선 순서로 번호를 + 이어 받는다. +- **method**: 리소스 수준·라우트 수준 가드의 교집합. method가 아닌 가드(`Header`·`Host`·`fn_guard`·매크로 `guard =`)는 + `narrowed: true`다(조건이 맞지 않는 요청은 다음 등록으로 넘어간다 — 계약의 narrowed 뜻과 같다). +- **channel**: 스코프 접두사와 리소스 경로를 actix 규칙으로 이은 뒤 패턴을 해석한다. `{name}`은 `{}`(부분 세그먼트면 + `p{}s`), 한 세그먼트에 파라미터 둘 이상은 dynamic과 `route-coverage:`. `{tail}*`·끝의 `{x:.*}`는 `{**}`와 빈 값 변형 + (`/files/`), `{x:.+}`는 `{**}`만. 그 밖에 `/`와 맞을 수 있는 정규식은 dynamic이다. +- **paramConstraints**: 정규식을 실행하지 않고 흔한 모양(`X+`·`X{n,m}`과 그 연결)만 읽어 ASCII 문자 집합으로 판정한다 + — 숫자면 `int`, `[-A-Za-z0-9_]` 부분집합이면 `slug`, hex 8-4-4-4-12면 `uuid`, `/`를 뺀 전부면 제약 없음, 그 밖은 + `regex`(원문 `pattern`). 빈 값과 맞는 정규식(`\d*`)과 읽지 못한 정규식(그룹·대안·플래그)은 dynamic이다. +- **trailingSlash**: `strict`. App(또는 스코프)의 `NormalizePath`가 Trim이면 끝 슬래시 없는 템플릿이 `optional`이고 + 빈 값 변형은 내지 않는다(그 요청은 트림돼 꼬리 파라미터에 닿지 않는다). Always면 끝 슬래시 있는 템플릿이 `optional`. + `{**}`로 끝나면 생략. +- **App 평가**: `App::new()`에서 시작하는 체인마다, 그리고 함수 본문 최상위의 `let app = App::new()…;`와 그 재대입 + (`app = app.route(..)`)을 따라간다. 함수 인자로 넘긴 App은 그 함수가 더 등록할 수 있어 `route-coverage:`로 센다. +- **location**: 매크로 속성의 경로 리터럴, `App::route`·`web::resource`의 경로 인자. +- 어느 App에도 등록되지 않은 매크로 핸들러와 리터럴이 아닌 스코프 아래 선언은 `pathAnchor: "base"`(order 없음)와 + `unresolved-route-prefix:`·`route-dispatch-order-unknown:`(`templateSuffixes` 스코프)이다. + +## 한계와 스코프 + +| 상황 | 결과 | +|---|---| +| 워크스페이스에 axum·actix-web 의존이 없음 | 사실 0건 + `route-coverage:` | +| 확인한 메이저 밖의 버전 | `route-framework-version-unknown:` | +| 리터럴·`&str` 상수가 아닌 경로 | dynamic 사실(원문) + `route-coverage:` | +| 평가하지 못한 nest·merge·서비스·설정 함수 | 그 자리 접두사가 리터럴이면 `templatePrefixes` 스코프의 `route-coverage:`, 아니면 스코프 없음 | +| 조건·반복 안의 actix `ServiceConfig` 등록 | `route-coverage:` | +| impl·trait 메서드 안의 `Router::new()`·`App::new()` | `route-coverage:`(추출기는 모듈 수준 함수만 평가한다) | +| 함수 인자로 넘긴 actix App | `route-coverage:` | +| axum Router fallback·actix App 기본 서비스 | 루트면 스코프 없는 `route-coverage:`, nest·스코프 아래면 그 접두사 스코프 | +| axum `nest_service` | `framework-provided-routes:` + 접두사 스코프 | +| actix-files `Files::new(prefix, ..)` | `framework-provided-routes:` + 접두사 스코프, `methods: ["GET","HEAD"]` | +| 기동 시 panic하는 axum 경로 | 스코프 없는 `route-coverage:`(선언 없음) | +| 원문 경로로만 닿는 axum 리터럴 | 그 템플릿 스코프의 `route-coverage:`(선언 없음) | +| CONNECT·사용자 동사 | `route-coverage:` | +| 클로저 핸들러 | 감싸는 함수 usr + `missing-route-usrs:`(체인 전용) | +| 서비스·리다이렉트·해석 못 한 핸들러 | usr 없음 + `missing-route-usrs:` | + +스코프는 문서를 내기 전에 계약 검사(`scope_problem`, 공유 벡터 `scope.validate`)를 통과해야 하고, 통과하지 못하면 +스코프 없이 남긴다. 템플릿 문법과 `order` 규칙(`dispatch.validate`)도 내기 전에 소비자와 같은 검사로 확인하며, +실패는 생산자 결함이라 문서를 내지 않고 오류로 보고한다. + +## 결정 사항 + +- **axum은 specificity, actix-web은 registration-order.** 위 소스로 확인했다. 두 근사의 실패 방향은 모두 거짓 match다. + - axum: 경로 우선(405) vs isthmus method 우선. + - actix: 라우트 수준 method 가드(`web::resource().route(web::get())`)는 405로 끝나지만 isthmus는 method를 먼저 거른다. + 스코프가 접두사를 잡으면 뒤 App 서비스로 넘어가지 않는데(`/api` 스코프 뒤의 `/api/x` 리소스는 닿지 않음) 계약에는 + 스코프가 없어 소비자가 뒤 리소스에 잇는다. 매크로·`App::route`의 리소스 수준 method 가드는 isthmus의 method 우선과 + 정확히 같지만, 소비자의 `route-decl-path-shadowed` 경고는 method가 달라 다음 리소스로 넘어가는 경우에도 날 수 있다. + - actix `web::get()`은 HEAD를 받지 않지만 소비자 `head-as-get`은 잇는다. +- **정규화 미들웨어가 닿지 않게 만든 선언도 선언이다.** Trim 아래 끝 슬래시 있는 리소스, Always 아래 끝 슬래시 없는 + 리소스는 요청이 닿지 않지만 `strict`로 낸다. 미들웨어 판정이 틀렸을 때 선언을 빼면 거짓 error가 되기 때문이다. +- **빈 값 변형은 실측으로만.** matchit의 중간 파라미터 빈 값 매칭은 문서에 없어 오라클로 확인한 뒤 계약의 "빈 값 변형" + 규칙으로 낸다. actix `{name}`(`[^/]+`)은 빈 값과 맞지 않아 펼치지 않는다. +- **HEAD·자동 OPTIONS는 내지 않는다**(pythograph·tsograph와 같다). 명시한 `head`·`options`는 낸다. +- **테스트 소스는 제외한다**(`sourceSets.tests: "excluded"`) — `#[cfg(test)]` 모듈과 `#[test]`·`#[tokio::test]`· + `#[actix_web::test]` 함수. tests/·examples/ 타깃은 수확 범위 밖이다. + +## 오라클 검증 + +`experiments/routes-oracle/`는 fixture의 **같은 lib.rs**를 crates.io의 진짜 axum 0.8.9·0.7.9·actix-web 4.15.0으로 컴파일해 +(패키지의 `[lib] path`가 fixture 소스를 가리킨다) 프로세스 안에서 요청을 보낸다(axum `tower::ServiceExt::oneshot`, actix +`test::init_service`·`call_service`). 핸들러는 자기 정점 ID를 본문(HEAD는 `x-handler` 헤더)으로 돌려준다. 판정 기준은 +"rustograph 사실만으로 isthmus 방식(세그먼트·method·끝 슬래시·제약, 구체성 또는 등록 순서)으로 고른 핸들러 = 프레임워크가 +실제로 디스패치한 핸들러"다. + +- 사실 요청(정밀도): 사실마다 제약을 만족하는 표본 경로(ANY는 GET·POST·DELETE)를 보낸다. +- 끝 슬래시 요청: `trailingSlash`가 있는 사실마다 슬래시를 뒤집은 경로를 보내 `strict`·`optional` 판정을 확인한다. +- 기대 요청(재현율): 사람이 적은 (method, 경로, 핸들러) — 구체성(`/items/special` vs `/items/{}`), 등록 순서(정수 id가 + 슬러그보다 먼저), 빈 값 변형, catch-all, HEAD, 설정 함수 등. +- 음성 요청: 어느 핸들러에도 닿지 않아야 하는 요청(404·405·fallback) — 사실도 아무것도 주장하지 않아야 한다. + actix는 가드 헤더 없는 요청이 헤더 가드 스코프에 닿지 않는지도 본다. + +| fixture | 정밀도 | 재현율 | 음성 | 끝 슬래시 | 요청 불가 | +|---|---|---|---|---|---| +| `tests/fixture-routes/axum08` (axum 0.8.9) | 24/24 | 23/23 | 12/12 | 22/22 | 1 (루트에 붙지 않은 base 라우터) | +| `tests/fixture-routes/axum07` (axum 0.7.9) | 14/14 | 11/11 | 6/6 | 13/13 | 0 | +| `tests/fixture-routes/actix` (actix-web 4.15.0) | 23/23 | 18/18 | 9/9 | 19/19 | 1 (등록되지 않은 매크로 핸들러) | + +오라클이 처음 잡아 규칙을 고친 것: matchit의 중간 파라미터 빈 값 매칭(빈 값 변형 추가), 0.8 앞 글자 붙은 파라미터의 +빈 값 매칭, axum이 원문 경로를 비교해 리터럴 중괄호 경로에 인코딩된 요청이 닿지 않는 것(선언 대신 스코프 한계), +actix Trim 아래 빈 꼬리 변형이 닿지 않는 것, 같은 템플릿의 정적 경로가 빈 값 변형을 가리는 것(GLM 리뷰 지적을 오라클로 +재현). 오라클의 HEAD→GET 예측은 axum(specificity)에서만 쓴다(actix `web::get()`은 HEAD를 받지 않는다). + +기록(`experiments/routes-oracle/recorded/*.json`)은 커밋하고 `tests/routes.rs`가 오프라인으로 지금 출력의 정적 사실이 +기록에서 검증된 사실(과 요청할 수 없는 base 사실)과 정확히 같은지 확인한다. 다시 기록하려면 +`experiments/routes-oracle/run_all.sh`(crates.io 내려받기만 네트워크를 쓴다). + +## isthmus 호환 + +isthmus `main`(`76b6141`, #131)은 `platform: "rust"`의 http `route-decl`을 specificity·registration-order 모두 받는다. +공유 벡터는 같은 커밋에서 벤더링했다(`conformance/`, `conformance.lock`). diff --git a/experiments/routes-oracle/Cargo.lock b/experiments/routes-oracle/Cargo.lock new file mode 100644 index 0000000..929c35c --- /dev/null +++ b/experiments/routes-oracle/Cargo.lock @@ -0,0 +1,1755 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.18.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab7d72c0bb26f10df574ea560177b4de2f27ee8f421b2c1e9b75628bd9db23" +dependencies = [ + "actix-codec", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367f814ad4afbac74f07df5001214da65f65e185c90ef56c4dd8df23f8695b9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8db2cf6f034e227c0c79825fbf11f3cad48569109515f62542b84d8abedb9376" +dependencies = [ + "actix-rt", + "actix-service", + "futures-core", + "futures-util", + "mio", + "socket2", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0128396dd7313f697ad05b21b1a7be7d4cbb81888704f55996e4a27db196bb4d" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96b09c4878563f8ab4a5fd0c59f9f0d6e0e9f60eb9b748526a0b9604fd89c50" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.5.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http 1.5.0", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c0cddce6b7505483307994f60d97c4b156020e1504d0cf1f05a21b1b325e64" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oracle-actix" +version = "0.0.0" +dependencies = [ + "actix-http", + "actix-web", + "http-body-util", + "oracle-common", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "oracle-axum07" +version = "0.0.0" +dependencies = [ + "axum 0.7.9", + "http-body-util", + "oracle-common", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "oracle-axum08" +version = "0.0.0" +dependencies = [ + "axum 0.8.9", + "http-body-util", + "oracle-common", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "oracle-common" +version = "0.0.0" +dependencies = [ + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/experiments/routes-oracle/Cargo.toml b/experiments/routes-oracle/Cargo.toml new file mode 100644 index 0000000..e1f2c43 --- /dev/null +++ b/experiments/routes-oracle/Cargo.toml @@ -0,0 +1,6 @@ +# 라우팅 오라클 — fixture의 같은 lib.rs를 crates.io의 진짜 프레임워크로 컴파일해 +# rustograph `routes`가 낸 사실마다 요청을 보내 실제 디스패치와 대조한다. +# rustograph 본체와 별개인 독립 워크스페이스다(본체는 이 의존성을 모른다). +[workspace] +members = ["common", "axum08", "axum07", "actix"] +resolver = "2" diff --git a/experiments/routes-oracle/actix/Cargo.toml b/experiments/routes-oracle/actix/Cargo.toml new file mode 100644 index 0000000..9a3bc42 --- /dev/null +++ b/experiments/routes-oracle/actix/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "oracle-actix" +version = "0.0.0" +edition = "2021" +publish = false + +# lib은 fixture 소스 그대로다 — 오라클이 요청을 보내는 라우터와 rustograph가 +# 읽은 선언이 같은 코드에서 나온다. +[lib] +name = "actix_app" +path = "../../../tests/fixture-routes/actix/actix_app/src/lib.rs" + +[[bin]] +name = "oracle-actix" +path = "src/main.rs" + +[dependencies] +oracle-common = { path = "../common" } +actix-web = "=4.15.0" +actix-http = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +serde_json = "1" diff --git a/experiments/routes-oracle/actix/src/main.rs b/experiments/routes-oracle/actix/src/main.rs new file mode 100644 index 0000000..9af4e75 --- /dev/null +++ b/experiments/routes-oracle/actix/src/main.rs @@ -0,0 +1,118 @@ +//! actix-web 4.15.0 오라클 — fixture `actix_app::app()`을 `test::init_service`로 띄워 요청한다. +//! 모든 요청에 `x-api: 1`을 실어 헤더 가드 스코프도 닿게 한다(가드 없는 음성 요청은 따로 본다). + +use actix_web::test; +use oracle_common::{evaluate, io_paths, load_facts, plan, write, Probe}; + +/// 기대 디스패치 — 등록 순서(정수 id가 슬러그보다 먼저), 스코프, 설정 함수, 꼬리 파라미터. +const EXPECTED: &[(&str, &str, &str)] = &[ + ("GET", "/", "actix_app::handlers::index"), + ("GET", "/api/items", "actix_app::handlers::items::list"), + ("GET", "/api/items/", "actix_app::handlers::items::list"), + ("POST", "/api/items", "actix_app::handlers::items::create"), + ("GET", "/api/items/42", "actix_app::handlers::items::show"), + ( + "GET", + "/api/items/abc", + "actix_app::handlers::items::by_slug", + ), + ("GET", "/api/ping", "actix_app::handlers::ping"), + ("GET", "/api/users/3", "actix_app::handlers::users::get"), + ( + "DELETE", + "/api/users/3", + "actix_app::handlers::users::delete", + ), + ("GET", "/api/a", "actix_app::handlers::multi"), + ("PATCH", "/api/b", "actix_app::handlers::multi"), + ("POST", "/api/cfg", "actix_app::handlers::cfg_post"), + ("GET", "/files/a/b.txt", "actix_app::handlers::files"), + ("GET", "/r1", "actix_app::handlers::routes_multi"), + ("POST", "/r2", "actix_app::handlers::routes_multi"), + ("GET", "/m", "actix_app::handlers::route_macro"), + ("PUT", "/m", "actix_app::handlers::route_macro"), + ("GET", "/v1/status", "actix_app::handlers::v1_status"), +]; + +/// 닿지 않아야 하는 요청 — method 불일치, 스코프 밖, 트림된 빈 꼬리. +const NEGATIVE: &[(&str, &str)] = &[ + ("POST", "/api/users/3"), + ("DELETE", "/r1"), + ("GET", "/r2"), + ("DELETE", "/m"), + ("GET", "/files/"), + ("GET", "/files"), + ("GET", "/lonely"), + ("GET", "/api/cfg"), +]; + +/// 요청 하나를 보낸다(가드 헤더 포함 여부 선택). +async fn send(app: &S, p: &Probe, header: bool) -> (u16, String) +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let method = actix_web::http::Method::from_bytes(p.method.as_bytes()).expect("method"); + let mut req = test::TestRequest::default().method(method).uri(&p.path); + if header { + req = req.insert_header(("x-api", "1")); + } + let resp = test::call_service(app, req.to_request()).await; + let status = resp.status().as_u16(); + let body = test::read_body(resp).await; + (status, String::from_utf8_lossy(&body).into_owned()) +} + +#[actix_web::main] +async fn main() -> std::process::ExitCode { + let (input, output) = io_paths(); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(input).expect("routes document")) + .expect("json"); + let (dispatch, facts) = load_facts(&doc); + let app = test::init_service(actix_app::app()).await; + let mut probes = plan(&facts, EXPECTED, NEGATIVE); + let mut results = Vec::new(); + for p in probes.drain(..) { + let (status, body) = send(&app, &p, true).await; + results.push((p, status, body)); + } + // 헤더 가드: 헤더 없는 요청은 v1 스코프를 건너뛰어 닿지 않아야 한다(narrowed 확인). + let unguarded = Probe { + kind: "negative", + method: "GET".into(), + path: "/v1/status".into(), + truth: Some(None), + fact: None, + }; + let (status, body) = send(&app, &unguarded, false).await; + let mut rec = evaluate( + "actix", + "actix-web 4.15.0 (actix-router 0.5.4)", + &dispatch, + &facts, + results, + ); + // narrowed 사실은 조건부라 사실 예측으로는 닿는다 — 실제로 닿지 않는지만 본다. + rec.negatives.total += 1; + let pass = status == 404; + if pass { + rec.negatives.passed += 1; + } + rec.probes.push(oracle_common::ProbeResult { + probe: unguarded, + predicted: oracle_common::Predicted::None, + status, + actual: if pass { + oracle_common::Actual::NoMatch + } else { + oracle_common::Actual::Other(body) + }, + pass, + }); + write(&rec, &output) +} diff --git a/experiments/routes-oracle/axum07/Cargo.toml b/experiments/routes-oracle/axum07/Cargo.toml new file mode 100644 index 0000000..e68edaa --- /dev/null +++ b/experiments/routes-oracle/axum07/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oracle-axum07" +version = "0.0.0" +edition = "2021" +publish = false + +# lib은 fixture 소스 그대로다 — 오라클이 요청을 보내는 라우터와 rustograph가 +# 읽은 선언이 같은 코드에서 나온다. +[lib] +name = "axum07_app" +path = "../../../tests/fixture-routes/axum07/axum07_app/src/lib.rs" + +[[bin]] +name = "oracle-axum07" +path = "src/main.rs" + +[dependencies] +oracle-common = { path = "../common" } +axum = "=0.7.9" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +serde_json = "1" diff --git a/experiments/routes-oracle/axum07/src/main.rs b/experiments/routes-oracle/axum07/src/main.rs new file mode 100644 index 0000000..a17c28c --- /dev/null +++ b/experiments/routes-oracle/axum07/src/main.rs @@ -0,0 +1,69 @@ +//! axum 0.7.9 오라클 — fixture `axum07_app::app()`에 `oneshot`으로 요청한다. + +use axum::body::Body; +use axum::http::Request; +use http_body_util::BodyExt; +use oracle_common::{evaluate, io_paths, load_facts, plan, write}; +use tower::ServiceExt; + +/// 기대 디스패치 — 0.7 문법(`:id`·`*rest`·접두사 파라미터·이름에 흡수된 접미사). +const EXPECTED: &[(&str, &str, &str)] = &[ + ("GET", "/items/5", "axum07_app::items_show"), + ("PATCH", "/items/5", "axum07_app::items_patch"), + ("GET", "/files/a/b", "axum07_app::files"), + ("GET", "/user_42", "axum07_app::user_prefixed"), + ("GET", "/f/report.json", "axum07_app::json_named"), + ("GET", "/f/report", "axum07_app::json_named"), + ("GET", "/api", "axum07_app::api_root"), + ("POST", "/api/echo", "axum07_app::api_echo"), + ("GET", "/orders//lines", "axum07_app::order_lines"), + ("GET", "/x_/y", "axum07_app::x_prefixed"), + ("GET", "/x_1/y", "axum07_app::x_prefixed"), +]; + +/// 닿지 않아야 하는 요청. +const NEGATIVE: &[(&str, &str)] = &[ + ("GET", "/files"), + ("GET", "/files/"), + ("GET", "/user_"), + ("GET", "/api/"), + ("GET", "/items/5/"), + ("POST", "/items/5"), +]; + +#[tokio::main] +async fn main() -> std::process::ExitCode { + let (input, output) = io_paths(); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(input).expect("routes document")) + .expect("json"); + let (dispatch, facts) = load_facts(&doc); + let probes = plan(&facts, EXPECTED, NEGATIVE); + let mut results = Vec::new(); + for p in probes { + let req = Request::builder() + .method(p.method.as_str()) + .uri(p.path.as_str()) + .body(Body::empty()) + .expect("request"); + let resp = axum07_app::app().oneshot(req).await.expect("infallible"); + let status = resp.status().as_u16(); + // HEAD 응답은 본문이 지워진다 — 핸들러가 헤더로 밝혔으면 그 값을 쓴다. + let marker = resp + .headers() + .get("x-handler") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let bytes = resp.into_body().collect().await.expect("body").to_bytes(); + let body = marker.unwrap_or_else(|| String::from_utf8_lossy(&bytes).into_owned()); + results.push((p, status, body)); + } + let rec = evaluate( + "axum07", + "axum 0.7.9 (matchit 0.7.3)", + &dispatch, + &facts, + results, + ); + write(&rec, &output) +} diff --git a/experiments/routes-oracle/axum08/Cargo.toml b/experiments/routes-oracle/axum08/Cargo.toml new file mode 100644 index 0000000..ff8238a --- /dev/null +++ b/experiments/routes-oracle/axum08/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oracle-axum08" +version = "0.0.0" +edition = "2021" +publish = false + +# lib은 fixture 소스 그대로다 — 오라클이 요청을 보내는 라우터와 rustograph가 +# 읽은 선언이 같은 코드에서 나온다. +[lib] +name = "axum_app" +path = "../../../tests/fixture-routes/axum08/axum_app/src/lib.rs" + +[[bin]] +name = "oracle-axum08" +path = "src/main.rs" + +[dependencies] +oracle-common = { path = "../common" } +axum = "=0.8.9" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +serde_json = "1" diff --git a/experiments/routes-oracle/axum08/src/main.rs b/experiments/routes-oracle/axum08/src/main.rs new file mode 100644 index 0000000..c0a0705 --- /dev/null +++ b/experiments/routes-oracle/axum08/src/main.rs @@ -0,0 +1,115 @@ +//! axum 0.8.9 오라클 — fixture `axum_app::app()`에 `tower::ServiceExt::oneshot`으로 요청한다. + +use axum::body::Body; +use axum::http::Request; +use http_body_util::BodyExt; +use oracle_common::{evaluate, io_paths, load_facts, plan, write}; +use tower::ServiceExt; + +/// 사람이 적은 기대 디스패치(재현율의 분모) — 구체성·nest·merge·catch-all·HEAD. +const EXPECTED: &[(&str, &str, &str)] = &[ + ("GET", "/", "axum_app::handlers::root"), + ("GET", "/health", "axum_app::handlers::health"), + ("HEAD", "/health", "axum_app::handlers::health_head"), + ("GET", "/api/items", "axum_app::handlers::items::list"), + ("POST", "/api/items", "axum_app::handlers::items::create"), + ("GET", "/api/items/42", "axum_app::handlers::items::show"), + ("PUT", "/api/items/42", "axum_app::handlers::items::update"), + ( + "DELETE", + "/api/items/42", + "axum_app::handlers::items::remove", + ), + ( + "GET", + "/api/items/special", + "axum_app::handlers::items::special", + ), + ( + "GET", + "/api/items/42/tags/red", + "axum_app::handlers::items::tag", + ), + ( + "GET", + "/api/files/a/b/c.txt", + "axum_app::handlers::files::serve", + ), + ("GET", "/api/files/x", "axum_app::handlers::files::serve"), + ("GET", "/api/v2/status", "axum_app::handlers::status"), + ( + "PATCH", + "/api/users/7", + "axum_app::handlers::users::any_method", + ), + ("GET", "/api/search", "axum_app::handlers::search"), + ("POST", "/api/search", "axum_app::handlers::search"), + ("GET", "/api/closure", "axum_app::routes::api"), + ( + "POST", + "/api/v/status", + "axum_app::handlers::status_literal", + ), + ( + "GET", + "/api/items//tags/red", + "axum_app::handlers::items::tag", + ), + ("GET", "/api/tag_x", "axum_app::handlers::tag_prefixed"), + ("GET", "/api/trailing/", "axum_app::handlers::trailing"), + ("GET", "/admin/stats", "axum_app::handlers::admin_stats"), + ("POST", "/admin/reset", "axum_app::handlers::admin_reset"), +]; + +/// 어느 핸들러에도 닿지 않아야 하는 요청(404·405·fallback). +const NEGATIVE: &[(&str, &str)] = &[ + ("GET", "/api/items/"), + ("GET", "/api/files"), + ("GET", "/api/files/"), + ("DELETE", "/api/search"), + ("GET", "/api/trailing"), + ("GET", "/health/"), + ("POST", "/admin/stats"), + ("GET", "/orphan"), + ("GET", "/api/lit/braces"), + ("GET", "/api/lit/%7Bbraces%7D"), + ("GET", "/api/v/status"), + ("GET", "/api/tag_"), +]; + +#[tokio::main] +async fn main() -> std::process::ExitCode { + let (input, output) = io_paths(); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(input).expect("routes document")) + .expect("json"); + let (dispatch, facts) = load_facts(&doc); + let probes = plan(&facts, EXPECTED, NEGATIVE); + let mut results = Vec::new(); + for p in probes { + let req = Request::builder() + .method(p.method.as_str()) + .uri(p.path.as_str()) + .body(Body::empty()) + .expect("request"); + let resp = axum_app::app().oneshot(req).await.expect("infallible"); + let status = resp.status().as_u16(); + // HEAD 응답은 본문이 지워진다 — 핸들러가 헤더로 밝혔으면 그 값을 쓴다. + let marker = resp + .headers() + .get("x-handler") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let bytes = resp.into_body().collect().await.expect("body").to_bytes(); + let body = marker.unwrap_or_else(|| String::from_utf8_lossy(&bytes).into_owned()); + results.push((p, status, body)); + } + let rec = evaluate( + "axum08", + "axum 0.8.9 (matchit 0.8.4)", + &dispatch, + &facts, + results, + ); + write(&rec, &output) +} diff --git a/experiments/routes-oracle/common/Cargo.toml b/experiments/routes-oracle/common/Cargo.toml new file mode 100644 index 0000000..c09e4d0 --- /dev/null +++ b/experiments/routes-oracle/common/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "oracle-common" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "1" diff --git a/experiments/routes-oracle/common/src/lib.rs b/experiments/routes-oracle/common/src/lib.rs new file mode 100644 index 0000000..23169fb --- /dev/null +++ b/experiments/routes-oracle/common/src/lib.rs @@ -0,0 +1,535 @@ +//! 라우팅 오라클 공용 부분 — 사실 읽기, 요청 계획, 결과 판정, 기록. +//! +//! 판정 기준: "rustograph 사실만으로 isthmus 방식(세그먼트 매칭·method·끝 슬래시· +//! 구체성 또는 등록 순서)으로 고른 핸들러"가 "프레임워크가 실제로 디스패치한 +//! 핸들러"와 같아야 한다. 요청은 프로세스 안에서만 보낸다(네트워크 없음). + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +/// routes 문서의 사실 하나(오라클이 쓰는 필드만). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Fact { + pub method: String, + pub channel: String, + #[serde(rename = "pathAnchor")] + pub path_anchor: String, + #[serde(rename = "trailingSlash", skip_serializing_if = "Option::is_none")] + pub trailing_slash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option<(String, u64)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub usr: Option, + #[serde(skip_serializing_if = "std::ops::Not::not", default)] + pub narrowed: bool, + #[serde( + rename = "paramConstraints", + skip_serializing_if = "Vec::is_empty", + default + )] + pub constraints: Vec<(usize, String, Option)>, +} + +/// routes 문서에서 정적 사실을 읽는다(dynamic 제외). +pub fn load_facts(doc: &Value) -> (String, Vec) { + let dispatch = doc["dispatch"] + .as_str() + .unwrap_or("specificity") + .to_string(); + let mut out = Vec::new(); + for f in doc["facts"].as_array().into_iter().flatten() { + if f["dynamic"].as_bool() == Some(true) { + continue; + } + out.push(Fact { + method: f["method"].as_str().unwrap_or_default().to_string(), + channel: f["channel"].as_str().unwrap_or_default().to_string(), + path_anchor: f["pathAnchor"].as_str().unwrap_or_default().to_string(), + trailing_slash: f["trailingSlash"].as_str().map(str::to_string), + order: f + .get("order") + .and_then(|o| Some((o["group"].as_str()?.to_string(), o["index"].as_u64()?))), + usr: f + .pointer("/symbol/usr") + .and_then(Value::as_str) + .map(str::to_string), + narrowed: f["narrowed"].as_bool().unwrap_or(false), + constraints: f["paramConstraints"] + .as_array() + .into_iter() + .flatten() + .map(|c| { + ( + c["segment"].as_u64().unwrap_or(0) as usize, + c["kind"].as_str().unwrap_or_default().to_string(), + c["pattern"].as_str().map(str::to_string), + ) + }) + .collect(), + }); + } + (dispatch, out) +} + +/// 한 요청. +#[derive(Clone, Debug, Serialize)] +pub struct Probe { + /// fact·trailing·expected·negative. + pub kind: &'static str, + pub method: String, + pub path: String, + /// 기대(ground truth) 핸들러 — expected·negative 요청만. + #[serde(skip_serializing_if = "Option::is_none")] + pub truth: Option>, + /// 이 요청을 만든 사실(fact·trailing). + #[serde(skip)] + pub fact: Option, +} + +/// 사실만으로 고른 결과. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", content = "usr")] +pub enum Predicted { + /// 어느 사실도 맞지 않음. + None, + /// usr 없는 사실(서비스 등)이 받음. + Unnamed, + /// 이 usr의 사실이 받음. + Handler(String), + /// 구체성 동률 — 모호. + Ambiguous, +} + +/// 실제 응답의 해석. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", content = "body")] +pub enum Actual { + /// 404·405 — 핸들러에 닿지 않음. + NoMatch, + /// 알려진 핸들러 ID를 본문으로 돌려줌. + Handler(String), + /// 그 밖의 성공 응답(서비스 등). + Other(String), +} + +/// 요청 계획 — 사실마다 표본 경로, 끝 슬래시 뒤집기, 기대·음성 요청. +pub fn plan( + facts: &[Fact], + expected: &[(&str, &str, &str)], + negatives: &[(&str, &str)], +) -> Vec { + let mut out = Vec::new(); + for (i, f) in facts.iter().enumerate() { + if f.path_anchor != "root" { + continue; + } + let path = sample(f); + let methods: Vec = if f.method == "ANY" { + vec!["GET".into(), "POST".into(), "DELETE".into()] + } else { + vec![f.method.clone()] + }; + for m in &methods { + out.push(Probe { + kind: "fact", + method: m.clone(), + path: path.clone(), + truth: None, + fact: Some(i), + }); + if f.trailing_slash.is_some() && path != "/" { + let toggled = match path.strip_suffix('/') { + Some(p) => p.to_string(), + None => format!("{path}/"), + }; + out.push(Probe { + kind: "trailing", + method: m.clone(), + path: toggled, + truth: None, + fact: Some(i), + }); + } + } + } + for (m, p, h) in expected { + out.push(Probe { + kind: "expected", + method: m.to_string(), + path: p.to_string(), + truth: Some(Some(h.to_string())), + fact: None, + }); + } + for (m, p) in negatives { + out.push(Probe { + kind: "negative", + method: m.to_string(), + path: p.to_string(), + truth: Some(None), + fact: None, + }); + } + out +} + +/// 사실의 표본 경로 — 파라미터를 제약에 맞는 값으로 채운다. +pub fn sample(f: &Fact) -> String { + let segs: Vec<&str> = f.channel[1..].split('/').collect(); + let mut out = String::new(); + for (i, seg) in segs.iter().enumerate() { + out.push('/'); + if *seg == "{**}" { + out.push_str("a/b"); + continue; + } + match seg.find("{}") { + None => out.push_str(seg), + Some(at) => { + let value = match f.constraints.iter().find(|c| c.0 == i) { + Some((_, k, _)) if k == "int" => "7".to_string(), + Some((_, k, _)) if k == "slug" => "ab-c".to_string(), + Some((_, k, _)) if k == "uuid" => { + "123e4567-e89b-12d3-a456-426614174000".to_string() + } + Some((_, _, Some(p))) => regex_sample(p), + _ => "x1".to_string(), + }; + out.push_str(&seg[..at]); + out.push_str(&value); + out.push_str(&seg[at + 2..]); + } + } + } + out +} + +/// 정규식 제약을 만족하는 후보 값. +fn regex_sample(p: &str) -> String { + let re = Regex::new(&format!("^(?:{p})$")).expect("constraint regex compiles"); + [ + "7", + "abc", + "ab-c", + "a.b", + "x1", + "A1", + "123e4567-e89b-12d3-a456-426614174000", + ] + .iter() + .find(|c| re.is_match(c)) + .map(|c| c.to_string()) + .unwrap_or_else(|| "x1".to_string()) +} + +/// 사실 템플릿이 요청 경로와 맞는가(끝 슬래시 optional이면 양쪽 형태). +fn matches(f: &Fact, path: &str) -> bool { + if matches_exact(f, path) { + return true; + } + if f.trailing_slash.as_deref() == Some("optional") && path != "/" { + let alt = match path.strip_suffix('/') { + Some(p) => p.to_string(), + None => format!("{path}/"), + }; + return matches_exact(f, &alt); + } + false +} + +fn matches_exact(f: &Fact, path: &str) -> bool { + let t: Vec<&str> = f.channel[1..].split('/').collect(); + let p: Vec<&str> = path[1..].split('/').collect(); + for (i, seg) in t.iter().enumerate() { + if *seg == "{**}" { + let rest = &p[i.min(p.len())..]; + return !rest.is_empty() && rest.iter().any(|s| !s.is_empty()); + } + let Some(value) = p.get(i) else { return false }; + match seg.find("{}") { + None => { + if seg != value { + return false; + } + } + Some(at) => { + let (pre, suf) = (&seg[..at], &seg[at + 2..]); + if value.len() <= pre.len() + suf.len() + || !value.starts_with(pre) + || !value.ends_with(suf) + { + return false; + } + let mid = &value[pre.len()..value.len() - suf.len()]; + if !constraint_ok(f, i, mid) { + return false; + } + } + } + } + t.len() == p.len() +} + +fn constraint_ok(f: &Fact, seg: usize, v: &str) -> bool { + match f.constraints.iter().find(|c| c.0 == seg) { + Some((_, k, _)) if k == "int" => Regex::new(r"^[+-]?[0-9]+$").unwrap().is_match(v), + Some((_, k, _)) if k == "slug" => Regex::new(r"^[-A-Za-z0-9_]+$").unwrap().is_match(v), + Some((_, k, _)) if k == "uuid" => Regex::new( + r"^(?i:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})$", + ) + .unwrap() + .is_match(v), + Some((_, _, Some(p))) => Regex::new(&format!("^(?:{p})$")).unwrap().is_match(v), + _ => true, + } +} + +/// 세그먼트 순위 — 리터럴 > 부분 > 제약 있는 `{}` > `{}` > `{**}`(isthmus 구체성). +fn ranks(f: &Fact) -> Vec { + f.channel[1..] + .split('/') + .enumerate() + .map(|(i, s)| { + if s == "{**}" { + 0 + } else if s == "{}" { + if f.constraints.iter().any(|c| c.0 == i) { + 2 + } else { + 1 + } + } else if s.contains("{}") { + 3 + } else { + 4 + } + }) + .collect() +} + +/// 사실만으로 요청을 받을 핸들러를 고른다. +pub fn predict(facts: &[Fact], dispatch: &str, method: &str, path: &str) -> Predicted { + // axum `get`은 HEAD도 받지만(method_routing.rs:1157-1159) actix `web::get()`은 + // 정확히 같은 동사만 받는다(guard/mod.rs:412) — HEAD→GET은 axum(specificity)에서만. + let head_as_get = dispatch == "specificity"; + let method_ok = |f: &Fact| { + f.method == method + || f.method == "ANY" + || (head_as_get && method == "HEAD" && f.method == "GET") + }; + let mut cands: Vec<&Fact> = facts + .iter() + .filter(|f| f.path_anchor == "root" && method_ok(f) && matches(f, path)) + .collect(); + if cands.is_empty() { + return Predicted::None; + } + // 명시 동사가 ANY·head-as-get보다 먼저다(같은 등록·같은 템플릿 안). + let exact = |f: &&Fact| f.method == method; + let pick = |f: &Fact| match &f.usr { + Some(u) => Predicted::Handler(u.clone()), + None => Predicted::Unnamed, + }; + if dispatch == "registration-order" { + cands.sort_by_key(|f| (f.order.clone().map(|o| o.1).unwrap_or(u64::MAX), !exact(f))); + return pick(cands[0]); + } + let best = cands.iter().map(|f| ranks(f)).max().expect("non-empty"); + let top: Vec<&&Fact> = cands.iter().filter(|f| ranks(f) == best).collect(); + let templates: BTreeSet<&str> = top.iter().map(|f| f.channel.as_str()).collect(); + if templates.len() > 1 { + return Predicted::Ambiguous; + } + let chosen = top.iter().find(|f| exact(f)).copied().unwrap_or(top[0]); + pick(chosen) +} + +/// 응답 해석 — 404·405는 미도달, 본문이 알려진 ID면 그 핸들러. +pub fn actual(status: u16, body: &str, known: &BTreeSet) -> Actual { + if status == 404 || status == 405 { + Actual::NoMatch + } else if known.contains(body) { + Actual::Handler(body.to_string()) + } else { + Actual::Other(body.to_string()) + } +} + +/// 예측과 실제가 같은가. +fn agrees(p: &Predicted, a: &Actual) -> bool { + match (p, a) { + (Predicted::None, Actual::NoMatch) => true, + (Predicted::Unnamed, Actual::Other(_)) => true, + (Predicted::Handler(u), Actual::Handler(b)) => u == b, + _ => false, + } +} + +/// 요청 하나의 기록. +#[derive(Serialize)] +pub struct ProbeResult { + #[serde(flatten)] + pub probe: Probe, + pub predicted: Predicted, + pub status: u16, + pub actual: Actual, + pub pass: bool, +} + +/// 합계. +#[derive(Serialize, Default)] +pub struct Tally { + pub passed: usize, + pub total: usize, +} + +/// 오라클 기록 — 커밋해 오프라인 테스트가 읽는다. +#[derive(Serialize)] +pub struct Recording { + pub fixture: String, + pub framework: String, + pub dispatch: String, + /// 모든 요청이 통과한 사실(정밀도의 분자). + #[serde(rename = "verifiedFacts")] + pub verified_facts: Vec, + /// 요청으로 확인할 수 없는 사실(base 앵커 — 붙는 곳이 없다)과 이유. + pub unprobed: Vec<(Fact, String)>, + /// 요청이 하나라도 실패한 사실. + #[serde(rename = "failedFacts")] + pub failed_facts: Vec, + pub precision: Tally, + pub recall: Tally, + pub negatives: Tally, + #[serde(rename = "trailingSlash")] + pub trailing: Tally, + pub probes: Vec, +} + +/// 실행 결과를 판정해 기록을 만든다. +pub fn evaluate( + fixture: &str, + framework: &str, + dispatch: &str, + facts: &[Fact], + results: Vec<(Probe, u16, String)>, +) -> Recording { + let mut known: BTreeSet = facts.iter().filter_map(|f| f.usr.clone()).collect(); + for (p, _, _) in &results { + if let Some(Some(h)) = &p.truth { + known.insert(h.clone()); + } + } + let mut failed: BTreeSet = BTreeSet::new(); + let (mut precision, mut recall, mut negatives, mut trailing) = ( + Tally::default(), + Tally::default(), + Tally::default(), + Tally::default(), + ); + let mut probes = Vec::new(); + for (probe, status, body) in results { + let predicted = predict(facts, dispatch, &probe.method, &probe.path); + let act = actual(status, &body, &known); + let pass = match &probe.truth { + // 기대 요청: 실제가 기대 핸들러이고 사실 예측도 같아야 한다(재현율). + Some(Some(h)) => { + act == Actual::Handler(h.clone()) && predicted == Predicted::Handler(h.clone()) + } + // 음성 요청: 실제로 닿지 않고 사실도 아무것도 주장하지 않는다. + Some(None) => act == Actual::NoMatch && predicted == Predicted::None, + None => agrees(&predicted, &act), + }; + let tally = match probe.kind { + "fact" => &mut precision, + "trailing" => &mut trailing, + "expected" => &mut recall, + _ => &mut negatives, + }; + tally.total += 1; + if pass { + tally.passed += 1; + } else if let Some(i) = probe.fact { + failed.insert(i); + } + probes.push(ProbeResult { + probe, + predicted, + status, + actual: act, + pass, + }); + } + let mut verified = Vec::new(); + let mut unprobed = Vec::new(); + let mut failed_facts = Vec::new(); + for (i, f) in facts.iter().enumerate() { + if f.path_anchor != "root" { + unprobed.push(( + f.clone(), + "pathAnchor base: the router is not mounted on the served app".to_string(), + )); + } else if failed.contains(&i) { + failed_facts.push(f.clone()); + } else { + verified.push(f.clone()); + } + } + Recording { + fixture: fixture.to_string(), + framework: framework.to_string(), + dispatch: dispatch.to_string(), + verified_facts: verified, + unprobed, + failed_facts, + precision, + recall, + negatives, + trailing, + probes, + } +} + +/// 명령행: ` <기록 출력 경로>`. +pub fn io_paths() -> (String, String) { + let args: Vec = std::env::args().collect(); + assert!( + args.len() == 3, + "usage: oracle " + ); + (args[1].clone(), args[2].clone()) +} + +/// 기록을 쓰고 요약을 출력한다. 정밀도·재현율이 100%가 아니면 실패 코드. +pub fn write(rec: &Recording, out: &str) -> std::process::ExitCode { + let text = serde_json::to_string_pretty(rec).expect("recording serializes") + "\n"; + std::fs::write(out, text).expect("recording written"); + println!( + "{}: precision {}/{} recall {}/{} negatives {}/{} trailing {}/{} unprobed {}", + rec.fixture, + rec.precision.passed, + rec.precision.total, + rec.recall.passed, + rec.recall.total, + rec.negatives.passed, + rec.negatives.total, + rec.trailing.passed, + rec.trailing.total, + rec.unprobed.len() + ); + for p in rec.probes.iter().filter(|p| !p.pass) { + println!( + " FAIL {} {} {} predicted={:?} actual={:?} status={}", + p.probe.kind, p.probe.method, p.probe.path, p.predicted, p.actual, p.status + ); + } + let ok = [&rec.precision, &rec.recall, &rec.negatives, &rec.trailing] + .iter() + .all(|t| t.passed == t.total); + if ok { + std::process::ExitCode::SUCCESS + } else { + std::process::ExitCode::FAILURE + } +} diff --git a/experiments/routes-oracle/recorded/actix.json b/experiments/routes-oracle/recorded/actix.json new file mode 100644 index 0000000..4906d60 --- /dev/null +++ b/experiments/routes-oracle/recorded/actix.json @@ -0,0 +1,1276 @@ +{ + "fixture": "actix", + "framework": "actix-web 4.15.0 (actix-router 0.5.4)", + "dispatch": "registration-order", + "verifiedFacts": [ + { + "method": "GET", + "channel": "/", + "pathAnchor": "root", + "trailingSlash": "strict", + "order": [ + "actix:actix_app::app", + 0 + ], + "usr": "actix_app::handlers::index" + }, + { + "method": "ANY", + "channel": "/api/a", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 7 + ], + "usr": "actix_app::handlers::multi" + }, + { + "method": "ANY", + "channel": "/api/b", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 7 + ], + "usr": "actix_app::handlers::multi" + }, + { + "method": "POST", + "channel": "/api/cfg", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 8 + ], + "usr": "actix_app::handlers::cfg_post" + }, + { + "method": "GET", + "channel": "/api/items", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 1 + ], + "usr": "actix_app::handlers::items::list" + }, + { + "method": "POST", + "channel": "/api/items", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 2 + ], + "usr": "actix_app::handlers::items::create" + }, + { + "method": "GET", + "channel": "/api/items/{}", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 3 + ], + "usr": "actix_app::handlers::items::show", + "paramConstraints": [ + [ + 2, + "int", + null + ] + ] + }, + { + "method": "GET", + "channel": "/api/items/{}", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 4 + ], + "usr": "actix_app::handlers::items::by_slug" + }, + { + "method": "GET", + "channel": "/api/ping", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 5 + ], + "usr": "actix_app::handlers::ping" + }, + { + "method": "DELETE", + "channel": "/api/users/{}", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 6 + ], + "usr": "actix_app::handlers::users::delete" + }, + { + "method": "GET", + "channel": "/api/users/{}", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 6 + ], + "usr": "actix_app::handlers::users::get" + }, + { + "method": "ANY", + "channel": "/files/{**}", + "pathAnchor": "root", + "order": [ + "actix:actix_app::app", + 9 + ], + "usr": "actix_app::handlers::files" + }, + { + "method": "GET", + "channel": "/m", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 12 + ], + "usr": "actix_app::handlers::route_macro" + }, + { + "method": "PUT", + "channel": "/m", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 12 + ], + "usr": "actix_app::handlers::route_macro" + }, + { + "method": "GET", + "channel": "/r1", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 10 + ], + "usr": "actix_app::handlers::routes_multi" + }, + { + "method": "POST", + "channel": "/r2", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 11 + ], + "usr": "actix_app::handlers::routes_multi" + }, + { + "method": "GET", + "channel": "/v1/status", + "pathAnchor": "root", + "trailingSlash": "optional", + "order": [ + "actix:actix_app::app", + 13 + ], + "usr": "actix_app::handlers::v1_status", + "narrowed": true + } + ], + "unprobed": [ + [ + { + "method": "GET", + "channel": "/lonely", + "pathAnchor": "base", + "trailingSlash": "strict", + "usr": "actix_app::handlers::lonely" + }, + "pathAnchor base: the router is not mounted on the served app" + ] + ], + "failedFacts": [], + "precision": { + "passed": 23, + "total": 23 + }, + "recall": { + "passed": 18, + "total": 18 + }, + "negatives": { + "passed": 9, + "total": 9 + }, + "trailingSlash": { + "passed": 19, + "total": 19 + }, + "probes": [ + { + "kind": "fact", + "method": "GET", + "path": "/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::index" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::index" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/a", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/a/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/a", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/a/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/api/a", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "DELETE", + "path": "/api/a/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/b", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/b/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/b", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/b/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/api/b", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "DELETE", + "path": "/api/b/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/cfg", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::cfg_post" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::cfg_post" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/cfg/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::cfg_post" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::cfg_post" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::list" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::list" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::list" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::list" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/items", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::create" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::create" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/items/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::create" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::create" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items/7", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::show" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/7/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::show" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items/x1", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::by_slug" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::by_slug" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/x1/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::by_slug" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::by_slug" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/ping", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::ping" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::ping" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/ping/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::ping" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::ping" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/api/users/x1", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::users::delete" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::users::delete" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "DELETE", + "path": "/api/users/x1/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::users::delete" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::users::delete" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/users/x1", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::users::get" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::users::get" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/users/x1/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::users::get" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::users::get" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/files/a/b", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::files" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::files" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/files/a/b", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::files" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::files" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/files/a/b", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::files" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::files" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/m", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::route_macro" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::route_macro" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/m/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::route_macro" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::route_macro" + }, + "pass": true + }, + { + "kind": "fact", + "method": "PUT", + "path": "/m", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::route_macro" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::route_macro" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "PUT", + "path": "/m/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::route_macro" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::route_macro" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/r1", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::routes_multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::routes_multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/r1/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::routes_multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::routes_multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/r2", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::routes_multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::routes_multi" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/r2/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::routes_multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::routes_multi" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/v1/status", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::v1_status" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::v1_status" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/v1/status/", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::v1_status" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::v1_status" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/", + "truth": "actix_app::handlers::index", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::index" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::index" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items", + "truth": "actix_app::handlers::items::list", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::list" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::list" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items/", + "truth": "actix_app::handlers::items::list", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::list" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::list" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/api/items", + "truth": "actix_app::handlers::items::create", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::create" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::create" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items/42", + "truth": "actix_app::handlers::items::show", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::show" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items/abc", + "truth": "actix_app::handlers::items::by_slug", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::items::by_slug" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::items::by_slug" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/ping", + "truth": "actix_app::handlers::ping", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::ping" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::ping" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/users/3", + "truth": "actix_app::handlers::users::get", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::users::get" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::users::get" + }, + "pass": true + }, + { + "kind": "expected", + "method": "DELETE", + "path": "/api/users/3", + "truth": "actix_app::handlers::users::delete", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::users::delete" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::users::delete" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/a", + "truth": "actix_app::handlers::multi", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "expected", + "method": "PATCH", + "path": "/api/b", + "truth": "actix_app::handlers::multi", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::multi" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/api/cfg", + "truth": "actix_app::handlers::cfg_post", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::cfg_post" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::cfg_post" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/files/a/b.txt", + "truth": "actix_app::handlers::files", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::files" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::files" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/r1", + "truth": "actix_app::handlers::routes_multi", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::routes_multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::routes_multi" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/r2", + "truth": "actix_app::handlers::routes_multi", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::routes_multi" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::routes_multi" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/m", + "truth": "actix_app::handlers::route_macro", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::route_macro" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::route_macro" + }, + "pass": true + }, + { + "kind": "expected", + "method": "PUT", + "path": "/m", + "truth": "actix_app::handlers::route_macro", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::route_macro" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::route_macro" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/v1/status", + "truth": "actix_app::handlers::v1_status", + "predicted": { + "kind": "Handler", + "usr": "actix_app::handlers::v1_status" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "actix_app::handlers::v1_status" + }, + "pass": true + }, + { + "kind": "negative", + "method": "POST", + "path": "/api/users/3", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 405, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "DELETE", + "path": "/r1", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/r2", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "DELETE", + "path": "/m", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/files/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/files", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/lonely", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/cfg", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 405, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/v1/status", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + } + ] +} diff --git a/experiments/routes-oracle/recorded/axum07.json b/experiments/routes-oracle/recorded/axum07.json new file mode 100644 index 0000000..5a7d6d4 --- /dev/null +++ b/experiments/routes-oracle/recorded/axum07.json @@ -0,0 +1,745 @@ +{ + "fixture": "axum07", + "framework": "axum 0.7.9 (matchit 0.7.3)", + "dispatch": "specificity", + "verifiedFacts": [ + { + "method": "GET", + "channel": "/api", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::api_root" + }, + { + "method": "POST", + "channel": "/api/echo", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::api_echo" + }, + { + "method": "GET", + "channel": "/f/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::json_named" + }, + { + "method": "GET", + "channel": "/files/{**}", + "pathAnchor": "root", + "usr": "axum07_app::files" + }, + { + "method": "GET", + "channel": "/items/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::items_show" + }, + { + "method": "PATCH", + "channel": "/items/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::items_patch" + }, + { + "method": "GET", + "channel": "/orders//lines", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::order_lines" + }, + { + "method": "GET", + "channel": "/orders/{}/lines", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::order_lines" + }, + { + "method": "ANY", + "channel": "/svc", + "pathAnchor": "root", + "trailingSlash": "strict" + }, + { + "method": "GET", + "channel": "/user_{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::user_prefixed" + }, + { + "method": "GET", + "channel": "/x_/y", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::x_prefixed" + }, + { + "method": "GET", + "channel": "/x_{}/y", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum07_app::x_prefixed" + } + ], + "unprobed": [], + "failedFacts": [], + "precision": { + "passed": 14, + "total": 14 + }, + "recall": { + "passed": 11, + "total": 11 + }, + "negatives": { + "passed": 6, + "total": 6 + }, + "trailingSlash": { + "passed": 13, + "total": 13 + }, + "probes": [ + { + "kind": "fact", + "method": "GET", + "path": "/api", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::api_root" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::api_root" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/echo", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::api_echo" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::api_echo" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/echo/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/f/x1", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::json_named" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::json_named" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/f/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/files/a/b", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::files" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::files" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/items/x1", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::items_show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::items_show" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/items/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "PATCH", + "path": "/items/x1", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::items_patch" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::items_patch" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "PATCH", + "path": "/items/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/orders//lines", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::order_lines" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::order_lines" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/orders//lines/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/orders/x1/lines", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::order_lines" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::order_lines" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/orders/x1/lines/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/svc", + "predicted": { + "kind": "Unnamed" + }, + "status": 200, + "actual": { + "kind": "Other", + "body": "service" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/svc/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/svc", + "predicted": { + "kind": "Unnamed" + }, + "status": 200, + "actual": { + "kind": "Other", + "body": "service" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/svc/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/svc", + "predicted": { + "kind": "Unnamed" + }, + "status": 200, + "actual": { + "kind": "Other", + "body": "service" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "DELETE", + "path": "/svc/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/user_x1", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::user_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::user_prefixed" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/user_x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/x_/y", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::x_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::x_prefixed" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/x_/y/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/x_x1/y", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::x_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::x_prefixed" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/x_x1/y/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/items/5", + "truth": "axum07_app::items_show", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::items_show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::items_show" + }, + "pass": true + }, + { + "kind": "expected", + "method": "PATCH", + "path": "/items/5", + "truth": "axum07_app::items_patch", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::items_patch" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::items_patch" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/files/a/b", + "truth": "axum07_app::files", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::files" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::files" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/user_42", + "truth": "axum07_app::user_prefixed", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::user_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::user_prefixed" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/f/report.json", + "truth": "axum07_app::json_named", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::json_named" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::json_named" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/f/report", + "truth": "axum07_app::json_named", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::json_named" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::json_named" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api", + "truth": "axum07_app::api_root", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::api_root" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::api_root" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/api/echo", + "truth": "axum07_app::api_echo", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::api_echo" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::api_echo" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/orders//lines", + "truth": "axum07_app::order_lines", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::order_lines" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::order_lines" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/x_/y", + "truth": "axum07_app::x_prefixed", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::x_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::x_prefixed" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/x_1/y", + "truth": "axum07_app::x_prefixed", + "predicted": { + "kind": "Handler", + "usr": "axum07_app::x_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum07_app::x_prefixed" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/files", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/files/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/user_", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/items/5/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "POST", + "path": "/items/5", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 405, + "actual": { + "kind": "NoMatch" + }, + "pass": true + } + ] +} diff --git a/experiments/routes-oracle/recorded/axum08.json b/experiments/routes-oracle/recorded/axum08.json new file mode 100644 index 0000000..d3e5f91 --- /dev/null +++ b/experiments/routes-oracle/recorded/axum08.json @@ -0,0 +1,1373 @@ +{ + "fixture": "axum08", + "framework": "axum 0.8.9 (matchit 0.8.4)", + "dispatch": "specificity", + "verifiedFacts": [ + { + "method": "GET", + "channel": "/", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::root" + }, + { + "method": "POST", + "channel": "/admin/reset", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::admin_reset" + }, + { + "method": "GET", + "channel": "/admin/stats", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::admin_stats" + }, + { + "method": "GET", + "channel": "/api/closure", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::routes::api" + }, + { + "method": "GET", + "channel": "/api/files/{**}", + "pathAnchor": "root", + "usr": "axum_app::handlers::files::serve" + }, + { + "method": "GET", + "channel": "/api/items", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::list" + }, + { + "method": "POST", + "channel": "/api/items", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::create" + }, + { + "method": "GET", + "channel": "/api/items//tags/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::tag" + }, + { + "method": "GET", + "channel": "/api/items/special", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::special" + }, + { + "method": "DELETE", + "channel": "/api/items/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::remove" + }, + { + "method": "GET", + "channel": "/api/items/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::show" + }, + { + "method": "PUT", + "channel": "/api/items/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::update" + }, + { + "method": "GET", + "channel": "/api/items/{}/tags/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::items::tag" + }, + { + "method": "GET", + "channel": "/api/search", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::search" + }, + { + "method": "POST", + "channel": "/api/search", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::search" + }, + { + "method": "GET", + "channel": "/api/tag_{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::tag_prefixed" + }, + { + "method": "GET", + "channel": "/api/trailing/", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::trailing" + }, + { + "method": "ANY", + "channel": "/api/users/{}", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::users::any_method" + }, + { + "method": "POST", + "channel": "/api/v/status", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::status_literal" + }, + { + "method": "GET", + "channel": "/api/v{}/status", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::status" + }, + { + "method": "GET", + "channel": "/health", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::health" + }, + { + "method": "HEAD", + "channel": "/health", + "pathAnchor": "root", + "trailingSlash": "strict", + "usr": "axum_app::handlers::health_head" + } + ], + "unprobed": [ + [ + { + "method": "GET", + "channel": "/orphan", + "pathAnchor": "base", + "trailingSlash": "strict", + "usr": "axum_app::handlers::orphan" + }, + "pathAnchor base: the router is not mounted on the served app" + ] + ], + "failedFacts": [], + "precision": { + "passed": 24, + "total": 24 + }, + "recall": { + "passed": 23, + "total": 23 + }, + "negatives": { + "passed": 12, + "total": 12 + }, + "trailingSlash": { + "passed": 22, + "total": 22 + }, + "probes": [ + { + "kind": "fact", + "method": "GET", + "path": "/", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::root" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::root" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/admin/reset", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::admin_reset" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::admin_reset" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/admin/reset/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/admin/stats", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::admin_stats" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::admin_stats" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/admin/stats/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/closure", + "predicted": { + "kind": "Handler", + "usr": "axum_app::routes::api" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::routes::api" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/closure/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/files/a/b", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::files::serve" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::files::serve" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::list" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::list" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/items", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::create" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::create" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/items/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items//tags/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::tag" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::tag" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items//tags/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items/special", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::special" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::special" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/special/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/api/items/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::remove" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::remove" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "DELETE", + "path": "/api/items/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::show" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "PUT", + "path": "/api/items/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::update" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::update" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "PUT", + "path": "/api/items/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/items/x1/tags/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::tag" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::tag" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/items/x1/tags/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/search", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::search" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::search" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/search/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/search", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::search" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::search" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/search/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/tag_x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::tag_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::tag_prefixed" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/tag_x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/trailing/", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::trailing" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::trailing" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/trailing", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/users/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::users::any_method" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::users::any_method" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/users/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/users/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::users::any_method" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::users::any_method" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/users/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "DELETE", + "path": "/api/users/x1", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::users::any_method" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::users::any_method" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "DELETE", + "path": "/api/users/x1/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "POST", + "path": "/api/v/status", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::status_literal" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::status_literal" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "POST", + "path": "/api/v/status/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/api/vx1/status", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::status" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::status" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/api/vx1/status/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "GET", + "path": "/health", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::health" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::health" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "GET", + "path": "/health/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "fact", + "method": "HEAD", + "path": "/health", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::health_head" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::health_head" + }, + "pass": true + }, + { + "kind": "trailing", + "method": "HEAD", + "path": "/health/", + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/", + "truth": "axum_app::handlers::root", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::root" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::root" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/health", + "truth": "axum_app::handlers::health", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::health" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::health" + }, + "pass": true + }, + { + "kind": "expected", + "method": "HEAD", + "path": "/health", + "truth": "axum_app::handlers::health_head", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::health_head" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::health_head" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items", + "truth": "axum_app::handlers::items::list", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::list" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::list" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/api/items", + "truth": "axum_app::handlers::items::create", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::create" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::create" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items/42", + "truth": "axum_app::handlers::items::show", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::show" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::show" + }, + "pass": true + }, + { + "kind": "expected", + "method": "PUT", + "path": "/api/items/42", + "truth": "axum_app::handlers::items::update", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::update" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::update" + }, + "pass": true + }, + { + "kind": "expected", + "method": "DELETE", + "path": "/api/items/42", + "truth": "axum_app::handlers::items::remove", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::remove" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::remove" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items/special", + "truth": "axum_app::handlers::items::special", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::special" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::special" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items/42/tags/red", + "truth": "axum_app::handlers::items::tag", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::tag" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::tag" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/files/a/b/c.txt", + "truth": "axum_app::handlers::files::serve", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::files::serve" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::files::serve" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/files/x", + "truth": "axum_app::handlers::files::serve", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::files::serve" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::files::serve" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/v2/status", + "truth": "axum_app::handlers::status", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::status" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::status" + }, + "pass": true + }, + { + "kind": "expected", + "method": "PATCH", + "path": "/api/users/7", + "truth": "axum_app::handlers::users::any_method", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::users::any_method" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::users::any_method" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/search", + "truth": "axum_app::handlers::search", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::search" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::search" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/api/search", + "truth": "axum_app::handlers::search", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::search" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::search" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/closure", + "truth": "axum_app::routes::api", + "predicted": { + "kind": "Handler", + "usr": "axum_app::routes::api" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::routes::api" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/api/v/status", + "truth": "axum_app::handlers::status_literal", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::status_literal" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::status_literal" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/items//tags/red", + "truth": "axum_app::handlers::items::tag", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::items::tag" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::items::tag" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/tag_x", + "truth": "axum_app::handlers::tag_prefixed", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::tag_prefixed" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::tag_prefixed" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/api/trailing/", + "truth": "axum_app::handlers::trailing", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::trailing" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::trailing" + }, + "pass": true + }, + { + "kind": "expected", + "method": "GET", + "path": "/admin/stats", + "truth": "axum_app::handlers::admin_stats", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::admin_stats" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::admin_stats" + }, + "pass": true + }, + { + "kind": "expected", + "method": "POST", + "path": "/admin/reset", + "truth": "axum_app::handlers::admin_reset", + "predicted": { + "kind": "Handler", + "usr": "axum_app::handlers::admin_reset" + }, + "status": 200, + "actual": { + "kind": "Handler", + "body": "axum_app::handlers::admin_reset" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/items/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/files", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/files/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "DELETE", + "path": "/api/search", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 405, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/trailing", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/health/", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "POST", + "path": "/admin/stats", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 405, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/orphan", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/lit/braces", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/lit/%7Bbraces%7D", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/v/status", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 405, + "actual": { + "kind": "NoMatch" + }, + "pass": true + }, + { + "kind": "negative", + "method": "GET", + "path": "/api/tag_", + "truth": null, + "predicted": { + "kind": "None" + }, + "status": 404, + "actual": { + "kind": "NoMatch" + }, + "pass": true + } + ] +} diff --git a/experiments/routes-oracle/run_all.sh b/experiments/routes-oracle/run_all.sh new file mode 100755 index 0000000..d9aff50 --- /dev/null +++ b/experiments/routes-oracle/run_all.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# 오라클 재기록: fixture마다 `rustograph routes`를 돌리고 진짜 프레임워크로 요청해 +# recorded/.json을 쓴다. crates.io 의존성 내려받기 외에 네트워크를 쓰지 않는다. +# 정밀도·재현율·음성·끝 슬래시 요청이 하나라도 실패하면 0이 아닌 코드로 끝난다. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +tmp="$(mktemp -d)" +cargo build --quiet --manifest-path "$repo/Cargo.toml" +cargo build --quiet --release --manifest-path "$here/Cargo.toml" +status=0 +for fx in axum08 axum07 actix; do + "$repo/target/debug/rustograph" routes --role server --dir "$repo/tests/fixture-routes/$fx" --out "$tmp/$fx.json" >/dev/null + "$here/target/release/oracle-$fx" "$tmp/$fx.json" "$here/recorded/$fx.json" || status=1 +done +rm -rf "$tmp" +exit $status diff --git a/scripts/verify-cli-contract.sh b/scripts/verify-cli-contract.sh index 0af4e75..24244c3 100755 --- a/scripts/verify-cli-contract.sh +++ b/scripts/verify-cli-contract.sh @@ -167,9 +167,43 @@ if [ "$got" -ne 0 ]; then fails=$((fails+1)) fi +# routes --role server — isthmus http 서버 문서. --role은 필수이고 server만 +# 받는다(생략을 server로 읽으면 client를 더할 때 명령의 뜻이 바뀐다). +# 사실 0건이어도 roles가 있으니 target은 http다(계약의 http 예외). +check 0 "routes" routes --role server +check 2 "routes no role" routes +check 2 "routes client role" routes --role client +check 2 "routes bad fw" routes --role server --framework rocket +check 2 "routes semantic" routes --role server --semantic +check 2 "routes positional" routes --role server stray +routes_field() { # routes_field <설명> <패턴> + "$BIN" routes --role server --dir "$3" 2>/dev/null | grep -q "$2" || { + echo "FAIL $1: missing $2" >&2 + fails=$((fails+1)) + } +} +cp -R tests/fixture-routes "$FIX/fixture-routes" +routes_field "routes empty target" '"target": "http"' "$FIX/fixture" +routes_field "routes roles" '"server"' "$FIX/fixture" +routes_field "routes platform" '"platform": "rust"' "$FIX/fixture-routes/axum08" +routes_field "routes axum" '"dispatch": "specificity"' "$FIX/fixture-routes/axum08" +routes_field "routes actix" '"dispatch": "registration-order"' "$FIX/fixture-routes/actix" +routes_field "routes decl" '"kind": "route-decl"' "$FIX/fixture-routes/actix" +# 핸들러 usr는 그래프 정점이어야 reach로 이어진다 — 문서의 usr를 root로 준다. +"$BIN" routes --role server --dir "$FIX/fixture-routes/axum08" --out "$FIX/routes.json" >/dev/null 2>&1 || { + echo "FAIL routes --out: document not written" >&2 + fails=$((fails+1)) +} +got=0 +"$BIN" reach --dir "$FIX/fixture-routes/axum08" --roots-from "$FIX/routes.json" >/dev/null 2>&1 || got=$? +if [ "$got" -ne 0 ]; then + echo "FAIL routes usr roots: expected 0 (every handler usr is a vertex), got $got" >&2 + fails=$((fails+1)) +fi + # --dir를 붙이지 않는 검사 — check()는 항상 fixture dir을 뒤에 붙이므로 # 나쁜 --dir 검증은 마지막 인자가 이기는(last-wins) 구조상 여기서 따로 한다. -for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz"; do +for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz" "routes --role server --dir /nonexistent-xyz"; do got=0 # shellcheck disable=SC2086 "$BIN" $c >/dev/null 2>&1 || got=$? diff --git a/src/cli.rs b/src/cli.rs index 89db85a..a083b77 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,6 +33,7 @@ usage: rustograph search QUERY [--max N] rustograph mcp [--dir DIR] [--graph FILE] [--config FILE] [--deps] [--tests] rustograph schema [--dir DIR] [--out FILE] + rustograph routes --role server [--dir DIR] [--out FILE] [--framework axum|actix] rustograph version shared flags: --deps --tests --retain-public --semantic --no-cache @@ -89,6 +90,7 @@ fn run_inner(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> Resul "paths" => cmd_paths(&a, out), "search" => cmd_search(&a, out), "schema" => cmd_schema(&a, out), + "routes" => cmd_routes(&a, out), "mcp" => mcp::cmd(&a, &mut std::io::stdin().lock(), out, err), "-h" | "--help" | "help" => { writeln!(out, "{USAGE}").ok(); @@ -149,6 +151,47 @@ fn cmd_schema(a: &Args, out: &mut dyn Write) -> Result { Ok(0) } +/// `routes --role server` — isthmus http 도메인의 서버 route-decl 문서를 낸다. +/// 클라이언트 route-call은 아직 내지 않으므로 `--role`은 server만 받는다 — +/// 생략을 server로 읽으면 나중에 client를 더할 때 같은 명령의 뜻이 바뀐다. +fn cmd_routes(a: &Args, out: &mut dyn Write) -> Result { + if let Some(bad) = a.unsupported(&["dir", "out", "role", "framework"]) { + return Err(format!( + "routes takes only --role, --dir, --out and --framework — {bad} is not supported" + )); + } + match a.get("role") { + Some("server") => {} + Some(other) => { + return Err(format!( + "routes --role {other} is not supported; only --role server is implemented" + )) + } + None => return Err("routes requires --role server".to_string()), + } + let framework = match a.get("framework") { + None => None, + Some(f) => Some( + source::routes::Framework::parse(f) + .ok_or_else(|| format!("unknown --framework {f} (axum|actix)"))?, + ), + }; + let dir = PathBuf::from(a.get("dir").unwrap_or(".")); + let opts = source::routes::RouteOptions { framework }; + let doc = source::routes::routes(&dir, VERSION, &opts)?; + let text = export::to_json(&doc); + match a.get("out") { + Some(p) => { + std::fs::write(p, &text).map_err(|e| format!("cannot write {p}: {e}"))?; + writeln!(out, "wrote {p}").ok(); + } + None => { + write!(out, "{text}").ok(); + } + } + Ok(0) +} + fn cmd_graph(a: &Args, out: &mut dyn Write) -> Result { let level = cli_args::level_of(a)?; // 수확은 항상 최대 깊이로 — 얕은 레벨은 투영이다. diff --git a/src/cli_args.rs b/src/cli_args.rs index b1ee3d4..b8c5286 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -81,6 +81,8 @@ const VALUE_FLAGS: &[&str] = &[ "max-reached", "revision", "generated-at", + "role", + "framework", ]; const BOOL_FLAGS: &[&str] = &[ "deps", diff --git a/src/harvest.rs b/src/harvest.rs index 960472c..42b4844 100644 --- a/src/harvest.rs +++ b/src/harvest.rs @@ -12,6 +12,8 @@ use syn::parse::Parser; use syn::spanned::Spanned; use syn::visit::Visit; +mod locals; + /// 수확 중간 산출물 — 문서 조립 전의 실측 카운터. #[derive(Default)] pub struct Harvest { @@ -106,7 +108,10 @@ pub struct BodyItem<'a> { /// Self가 가리키는 타입(impl 안이면 Some). pub self_ty: Option>, /// 블록의 표현식들 — 문 위치 매크로는 ExprMacro로 감싸져 있다. + /// `let` 문은 패턴을 잃지 않도록 `Expr::Let`으로 담는다(지역 가림). pub exprs: Vec, + /// 매개변수가 묶는 이름 — 본문 전체에서 같은 이름의 아이템을 가린다. + pub params: Vec, pub signature_surface: Vec<&'a syn::Type>, /// 소유 아이템의 `#[cfg]` — 이 본문이 만드는 간선 전부가 그 조건 아래 있다. pub cfg: Option, @@ -125,10 +130,25 @@ fn block_exprs(b: &syn::Block) -> Vec { for s in &b.stmts { match s { syn::Stmt::Expr(e, _) => out.push(e.clone()), + // `let` 문은 패턴과 함께 `Expr::Let`으로 담는다 — 방문자가 + // 초기식을 본 뒤 패턴의 이름을 지역으로 묶어, 뒤따르는 표현식의 + // 같은 이름이 아이템(모듈·함수)으로 해석되지 않게 한다. + // `let .. else { }`의 else 블록은 묶음이 보이지 않으므로 먼저 둔다. syn::Stmt::Local(l) => { - if let Some(i) = &l.init { - out.push((*i.expr).clone()); + if let Some((_, diverge)) = l.init.as_ref().and_then(|i| i.diverge.as_ref()) { + out.push((**diverge).clone()); } + let init = match &l.init { + Some(i) => (*i.expr).clone(), + None => syn::Expr::Verbatim(proc_macro2::TokenStream::new()), + }; + out.push(syn::Expr::Let(syn::ExprLet { + attrs: l.attrs.clone(), + let_token: l.let_token, + pat: Box::new(l.pat.clone()), + eq_token: Default::default(), + expr: Box::new(init), + })); } syn::Stmt::Macro(m) => out.push(syn::Expr::Macro(syn::ExprMacro { attrs: m.attrs.clone(), @@ -201,6 +221,7 @@ pub fn decls<'a>( module: module_path.to_string(), self_ty: None, exprs, + params: locals::param_bindings(&f.sig), signature_surface: fn_signature_types(&f.sig), cfg: cfg.clone(), file: file.to_path_buf(), @@ -249,6 +270,7 @@ pub fn decls<'a>( module: module_path.to_string(), self_ty: None, exprs, + params: locals::param_bindings(&m.sig), signature_surface: fn_signature_types(&m.sig), cfg: cfg.clone(), file: file.to_path_buf(), @@ -285,6 +307,7 @@ pub fn decls<'a>( module: module_path.to_string(), self_ty: None, exprs, + params: Vec::new(), signature_surface: vec![&c.ty], cfg: cfg.clone(), file: file.to_path_buf(), @@ -303,6 +326,7 @@ pub fn decls<'a>( module: module_path.to_string(), self_ty: None, exprs, + params: Vec::new(), signature_surface: vec![&s.ty], cfg: cfg.clone(), file: file.to_path_buf(), @@ -477,6 +501,7 @@ pub fn impls<'a>( module: b.items_module.clone(), self_ty: Some(b.self_ty.clone()), exprs, + params: locals::param_bindings(&m.sig), signature_surface: fn_signature_types(&m.sig), cfg: b.cfg.clone(), file: b.file.clone(), @@ -567,6 +592,7 @@ pub fn body_edges( method_index, edge_cfg: &b.cfg, in_unsafe: 0, + scopes: locals::Scopes::with_params(&b.params), edges: Vec::new(), unresolved: 0, fanned: 0, @@ -616,6 +642,8 @@ struct BodyVisitor<'a> { edge_cfg: &'a Option, /// 현재 unsafe 블록 깊이 — 0보다 크면 간선에 unsafe를 찍는다. in_unsafe: usize, + /// 지역 묶음 스택 — 단일 식별자 경로는 아이템보다 지역을 먼저 본다. + scopes: locals::Scopes, edges: Vec, unresolved: usize, fanned: usize, @@ -653,6 +681,51 @@ impl BodyVisitor<'_> { fn resolve(&mut self, segs: &[String]) -> Option { self.tree.resolve(self.module, segs, self.dep_crates) } + + /// 값 자리(표현식 경로·호출 대상·포맷 캡처)의 경로를 해석한다. + /// + /// rustc의 값 이름공간 규칙 두 가지를 지킨다. 단일 식별자가 지역 + /// 묶음이거나 `self` 수신자면 아이템을 찾지 않는다(지역이 먼저다). + /// 해석 결과가 워크스페이스 모듈이면 버린다 — 모듈은 타입 이름공간에만 + /// 있어 값이 될 수 없으므로, 그런 해석은 지역 변수를 같은 이름의 + /// 모듈로 오독한 것이다. 외부 크레이트로 붕괴한 정점은 모듈 표에 없어 + /// 그대로 남는다(`dep::f()`는 크레이트 경계 증거다). + fn resolve_value(&mut self, segs: &[String]) -> ValueRes { + if let [only] = segs { + if only == "self" || self.scopes.is_local(only) { + return ValueRes::Local; + } + } + match self.resolve(segs) { + Some(id) if self.tree.modules.contains_key(&id) && !self.value_item(&id) => { + ValueRes::Local + } + Some(id) => ValueRes::Item(id), + None => ValueRes::Unresolved, + } + } + + /// 모듈 경로가 같은 이름의 값 아이템(fn·const·static)과도 겹치는가 — 그때 값 + /// 자리의 해석은 그 아이템이다(모듈과 값은 이름공간이 다르다). + fn value_item(&self, id: &str) -> bool { + let Some((parent, name)) = id.rsplit_once("::") else { + return false; + }; + self.tree + .modules + .get(parent) + .is_some_and(|m| m.value_items.contains(name)) + } +} + +/// 값 자리 경로의 해석 결과다. +enum ValueRes { + /// 크레이트 아이템(또는 외부 크레이트 경계 정점). + Item(String), + /// 지역 묶음·`self`·값이 될 수 없는 모듈 — 간선도 미해석 계수도 없다. + Local, + /// 아이템 표에서 찾지 못함. + Unresolved, } impl Visit<'_> for BodyVisitor<'_> { @@ -660,9 +733,11 @@ impl Visit<'_> for BodyVisitor<'_> { // foo::bar() — 경로 호출은 정확히 해석된다. if let syn::Expr::Path(p) = &*e.func { let segs = path_segments(&p.path); - match self.resolve(&segs) { - Some(id) => self.push(id, EdgeKind::Call), - None => { + match self.resolve_value(&segs) { + ValueRes::Item(id) => self.push(id, EdgeKind::Call), + // 지역 클로저·함수 값 호출 — 아이템 호출이 아니다. + ValueRes::Local => {} + ValueRes::Unresolved => { // Type::assoc_fn() — 마지막 세그먼트가 연관 함수다. // 앞부분이 타입 정점이면 그 타입의 메서드로 좁힌다. let n = segs.len(); @@ -748,7 +823,7 @@ impl Visit<'_> for BodyVisitor<'_> { }) = ex { for name in format_captures(&s.value()) { - if let Some(id) = self.resolve(&[name]) { + if let ValueRes::Item(id) = self.resolve_value(&[name]) { self.push(id, EdgeKind::References); } } @@ -777,13 +852,105 @@ impl Visit<'_> for BodyVisitor<'_> { self.in_unsafe -= 1; } + // ── 지역 스코프 관리 — 묶음을 만드는 구문마다 겹을 열고 닫는다. ── + + fn visit_expr_let(&mut self, e: &syn::ExprLet) { + // 초기식은 새 묶음을 못 본다(`let x = x + 1`의 오른쪽은 바깥 x). + self.visit_expr(&e.expr); + syn::visit::visit_pat(self, &e.pat); + self.scopes.bind(&e.pat); + } + + fn visit_block(&mut self, b: &syn::Block) { + self.scopes.push(); + for stmt in &b.stmts { + match stmt { + syn::Stmt::Local(l) => { + if let Some(init) = &l.init { + self.visit_expr(&init.expr); + if let Some((_, diverge)) = &init.diverge { + self.visit_expr(diverge); + } + } + syn::visit::visit_pat(self, &l.pat); + self.scopes.bind(&l.pat); + } + other => self.visit_stmt(other), + } + } + self.scopes.pop(); + } + + fn visit_item(&mut self, i: &syn::Item) { + // 블록 안 아이템은 바깥 지역을 캡처하지 않는다. 블록 안 fn은 자기 + // 매개변수를 첫 겹으로 가진다. + let saved = self.scopes.take(); + if let syn::Item::Fn(f) = i { + self.scopes = locals::Scopes::with_params(&locals::param_bindings(&f.sig)); + } + syn::visit::visit_item(self, i); + self.scopes.restore(saved); + } + + fn visit_expr_if(&mut self, e: &syn::ExprIf) { + // `if let` 묶음은 조건과 then 블록에서만 보이고 else에서는 안 보인다. + self.scopes.push(); + self.visit_expr(&e.cond); + self.visit_block(&e.then_branch); + self.scopes.pop(); + if let Some((_, els)) = &e.else_branch { + self.visit_expr(els); + } + } + + fn visit_expr_while(&mut self, e: &syn::ExprWhile) { + self.scopes.push(); + self.visit_expr(&e.cond); + self.visit_block(&e.body); + self.scopes.pop(); + } + + fn visit_expr_for_loop(&mut self, e: &syn::ExprForLoop) { + self.visit_expr(&e.expr); + self.scopes.push(); + syn::visit::visit_pat(self, &e.pat); + self.scopes.bind(&e.pat); + self.visit_block(&e.body); + self.scopes.pop(); + } + + fn visit_expr_match(&mut self, e: &syn::ExprMatch) { + self.visit_expr(&e.expr); + for arm in &e.arms { + self.scopes.push(); + syn::visit::visit_pat(self, &arm.pat); + self.scopes.bind(&arm.pat); + if let Some((_, guard)) = &arm.guard { + self.visit_expr(guard); + } + self.visit_expr(&arm.body); + self.scopes.pop(); + } + } + + fn visit_expr_closure(&mut self, e: &syn::ExprClosure) { + self.scopes.push(); + for input in &e.inputs { + syn::visit::visit_pat(self, input); + self.scopes.bind(input); + } + self.visit_expr(&e.body); + self.scopes.pop(); + } + fn visit_expr_path(&mut self, e: &syn::ExprPath) { // 호출이 아닌 경로 참조 — filter_map(f) 같은 함수 값 포함. // 단일 식별자는 모듈 아이템이면 잡고 지역 변수면 조용히 넘긴다. let segs = path_segments(&e.path); - match self.resolve(&segs) { - Some(id) => self.push(id, EdgeKind::References), - None => { + match self.resolve_value(&segs) { + ValueRes::Item(id) => self.push(id, EdgeKind::References), + ValueRes::Local => {} + ValueRes::Unresolved => { // E::V 형태 — 마지막 세그먼트가 열거형 배리언트/상수면 // 앞부분 타입에의 참조다. 그것도 안 되면 진짜 미해석. if segs.len() >= 2 { diff --git a/src/harvest/locals.rs b/src/harvest/locals.rs new file mode 100644 index 0000000..6ad7f13 --- /dev/null +++ b/src/harvest/locals.rs @@ -0,0 +1,153 @@ +//! 본문 지역 묶음(let·매개변수·클로저·패턴) 스코프. +//! +//! 표현식 위치의 단일 식별자 경로는 rustc에서 지역 묶음이 아이템보다 먼저 +//! 해석된다. 이 순서를 지키지 않으면 `fn f(repo: &Repo) { repo.all() }`의 +//! `repo`가 같은 이름의 크레이트 루트 모듈 `crate::repo`로 읽혀 가짜 +//! references 간선이 되고, 순회가 그 모듈이 import한 아이템 전부로 번진다. +//! 이 모듈은 이름만 모은다 — 무엇을 가리는지는 방문자가 정한다. + +use std::collections::BTreeSet; + +/// 중첩 블록마다 한 겹씩 쌓는 지역 이름 스택이다. +/// +/// 흐름 민감 분석이 아니라 어휘 스코프 근사다: 한 블록 안에서는 `let` +/// 문 뒤부터 이름이 보이고(방문 순서로 보장), 블록을 나가면 사라진다. +#[derive(Default)] +pub(super) struct Scopes { + frames: Vec>, +} + +impl Scopes { + /// 매개변수 이름으로 첫 겹을 만든다 — 함수 본문 전체에서 보인다. + pub(super) fn with_params(params: &[String]) -> Scopes { + Scopes { + frames: vec![params.iter().cloned().collect()], + } + } + + /// 새 겹을 연다(블록·match 팔·클로저·for 본문·if/while 조건+본문). + pub(super) fn push(&mut self) { + self.frames.push(BTreeSet::new()); + } + + /// 가장 안쪽 겹을 닫는다. 첫 겹(매개변수)은 닫지 않는다 — 짝이 안 맞는 + /// 방문이 있어도 매개변수 가림이 사라지지 않게 하기 위해서다. + pub(super) fn pop(&mut self) { + if self.frames.len() > 1 { + self.frames.pop(); + } + } + + /// 패턴이 묶는 이름을 가장 안쪽 겹에 더한다. + pub(super) fn bind(&mut self, pat: &syn::Pat) { + let mut names = Vec::new(); + pat_bindings(pat, &mut names); + if self.frames.is_empty() { + self.frames.push(BTreeSet::new()); + } + if let Some(top) = self.frames.last_mut() { + top.extend(names); + } + } + + /// 지역 묶음인가 — 어느 겹에든 있으면 아이템보다 먼저 해석된다. + pub(super) fn is_local(&self, name: &str) -> bool { + self.frames.iter().any(|f| f.contains(name)) + } + + /// 중첩 아이템(블록 안 `fn`)은 바깥 지역을 캡처하지 않는다 — 방문하는 + /// 동안 스택을 비웠다가 되돌린다. + pub(super) fn take(&mut self) -> Vec> { + std::mem::take(&mut self.frames) + } + + /// `take`로 비운 스택을 되돌린다. + pub(super) fn restore(&mut self, frames: Vec>) { + self.frames = frames; + } +} + +/// 함수 시그니처의 매개변수가 묶는 이름이다(`self` 수신자는 경로 판정에서 +/// 따로 처리하므로 넣지 않는다). +pub(super) fn param_bindings(sig: &syn::Signature) -> Vec { + let mut names = Vec::new(); + for arg in &sig.inputs { + if let syn::FnArg::Typed(t) = arg { + pat_bindings(&t.pat, &mut names); + } + } + names +} + +/// 패턴이 새로 묶는 식별자를 모은다. +/// +/// 식별자 패턴은 상수·유닛 구조체·유닛 배리언트를 가리킬 수도 있다 +/// (`match x { MAX => .. }`). rustc는 그 이름이 스코프의 그런 아이템이면 +/// 묶음이 아니라 비교로 읽는다. 구문 수확은 아이템 종류를 모르므로 Rust +/// 명명 관례로 근사한다: `ref`·`mut`·`@` 하위 패턴이 붙었거나 대문자로 +/// 시작하지 않으면 묶음이다. 소문자 상수 패턴은 그 스코프에서 같은 이름 +/// 참조를 잃는 쪽(간선 누락)으로 틀린다 — 가짜 간선보다 드물고 안전하다. +pub(super) fn pat_bindings(pat: &syn::Pat, out: &mut Vec) { + match pat { + syn::Pat::Ident(p) => { + let name = p.ident.to_string(); + let marked = p.by_ref.is_some() || p.mutability.is_some() || p.subpat.is_some(); + let upper = name.chars().next().is_some_and(char::is_uppercase); + if marked || !upper { + out.push(name); + } + if let Some((_, sub)) = &p.subpat { + pat_bindings(sub, out); + } + } + syn::Pat::Struct(p) => { + for f in &p.fields { + pat_bindings(&f.pat, out); + } + } + syn::Pat::TupleStruct(p) => p.elems.iter().for_each(|e| pat_bindings(e, out)), + syn::Pat::Tuple(p) => p.elems.iter().for_each(|e| pat_bindings(e, out)), + syn::Pat::Slice(p) => p.elems.iter().for_each(|e| pat_bindings(e, out)), + syn::Pat::Or(p) => p.cases.iter().for_each(|e| pat_bindings(e, out)), + syn::Pat::Reference(p) => pat_bindings(&p.pat, out), + syn::Pat::Type(p) => pat_bindings(&p.pat, out), + syn::Pat::Paren(p) => pat_bindings(&p.pat, out), + // 리터럴·범위·경로·와일드카드·나머지·매크로 패턴은 이름을 묶지 않는다. + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(src: &str) -> Vec { + let pat = syn::parse::Parser::parse_str(syn::Pat::parse_multi, src).unwrap(); + let mut out = Vec::new(); + pat_bindings(&pat, &mut out); + out + } + + #[test] + fn identifier_patterns_follow_binding_rules() { + assert_eq!(names("repo"), ["repo"]); + assert_eq!(names("MAX"), Vec::::new()); + assert_eq!(names("ref mut Big"), ["Big"]); + assert_eq!(names("(a, [b, ..], S { c, d: e })"), ["a", "b", "c", "e"]); + assert_eq!(names("Some(x) | Other(x)"), ["x", "x"]); + assert_eq!(names("whole @ Some(inner)"), ["whole", "inner"]); + assert_eq!(names("1..=9"), Vec::::new()); + } + + #[test] + fn scopes_hide_names_after_pop_but_keep_params() { + let mut s = Scopes::with_params(&["p".to_string()]); + s.push(); + s.bind(&syn::parse_quote!(inner)); + assert!(s.is_local("inner") && s.is_local("p")); + s.pop(); + assert!(!s.is_local("inner")); + s.pop(); + assert!(s.is_local("p")); + } +} diff --git a/src/modtree.rs b/src/modtree.rs index bd6248b..2d63abd 100644 --- a/src/modtree.rs +++ b/src/modtree.rs @@ -89,6 +89,9 @@ pub struct Module { pub dir: PathBuf, /// 직접 선언된 아이템 이름들(모듈 스코프 해석용). pub items: BTreeSet, + /// 그중 값 이름공간의 아이템(fn·const·static) — `mod util`과 `fn util`처럼 + /// 이름공간이 다른 두 아이템이 한 경로를 공유할 때 값 자리 해석이 fn을 고른다. + pub value_items: BTreeSet, /// `use` 임포트 맵: 마지막 세그먼트(또는 as 이름) → 임포트. pub imports: BTreeMap, /// 자식 모듈 이름 → 경로. @@ -106,6 +109,7 @@ impl Module { public, cfg: None, items: BTreeSet::new(), + value_items: BTreeSet::new(), imports: BTreeMap::new(), children: BTreeMap::new(), } @@ -397,10 +401,12 @@ pub fn cfg_of(attrs: &[syn::Attribute]) -> Option { /// 따라 크로스 크레이트 임포트가 조용히 유실된다. pub fn fill_items(tree: &mut ModTree, path: &str, items: &[&syn::Item]) { let mut names = BTreeSet::new(); + let mut values = BTreeSet::new(); for item in items { match item { syn::Item::Fn(f) => { names.insert(f.sig.ident.to_string()); + values.insert(f.sig.ident.to_string()); } syn::Item::Struct(s) => { names.insert(s.ident.to_string()); @@ -419,9 +425,11 @@ pub fn fill_items(tree: &mut ModTree, path: &str, items: &[&syn::Item]) { } syn::Item::Const(c) => { names.insert(c.ident.to_string()); + values.insert(c.ident.to_string()); } syn::Item::Static(s) => { names.insert(s.ident.to_string()); + values.insert(s.ident.to_string()); } syn::Item::Mod(m) => { names.insert(m.ident.to_string()); @@ -434,11 +442,9 @@ pub fn fill_items(tree: &mut ModTree, path: &str, items: &[&syn::Item]) { _ => {} } } - tree.modules - .get_mut(path) - .expect("module must exist") - .items - .extend(names); + let module = tree.modules.get_mut(path).expect("module must exist"); + module.items.extend(names); + module.value_items.extend(values); } /// 모듈의 `use` 임포트 맵을 해석해 채운다(2단계 — 전 모듈의 fill_items 이후). diff --git a/src/source.rs b/src/source.rs index 7f354cb..383224b 100644 --- a/src/source.rs +++ b/src/source.rs @@ -3,6 +3,7 @@ //! 외부 의존(cargo, syn)은 이 파일과 modtree/harvest/cargo_meta에만 있다. //! 수확은 판단하지 않는다: 해석 불가·조건부·외부 참조는 전부 실측 limitation이다. +pub mod routes; pub mod schema; use crate::cargo_meta::{self, Metadata}; @@ -88,9 +89,64 @@ pub fn owner_spans( symbol_level: true, ..Default::default() }; - let mut spans = Vec::new(); - let (doc, _) = harvest(dir, meta, &opts, Some(&mut spans))?; - Ok((doc, spans)) + let mut sink = Sink { + spans: Some(Vec::new()), + ..Default::default() + }; + let (doc, _) = harvest(dir, meta, &opts, Some(&mut sink))?; + Ok((doc, sink.spans.unwrap_or_default())) +} + +/// 수확 부산물을 받는 그릇 — 필요한 칸만 Some으로 채워 넘긴다. +/// +/// `schema`는 정점 범위만, `routes`는 모듈 트리와 AST 아레나까지 받는다. 라우트 +/// 추출이 핸들러 경로를 `impact`와 같은 정점 ID로 해석하려면 같은 수확의 이름 +/// 표가 필요하다 — 따로 다시 만들면 ID 규칙이 갈라진다. +#[derive(Default)] +struct Sink { + spans: Option>, + tree: Option, + arena: Option, + want_tree: bool, +} + +/// `routes`가 쓰는 수확 산출물 — 문서·정점 범위·모듈 트리·AST 아레나. +pub(crate) struct Parts { + pub(crate) doc: Document, + pub(crate) spans: Vec, + pub(crate) tree: ModTree, + arena: Arena, +} + +impl Parts { + /// 모듈 하나의 (선언 파일, 아이템 목록) 묶음 — 인라인 모듈과 lib/bin 합본 + /// 루트를 수확과 같은 규칙으로 푼다. + pub(crate) fn module_items(&self, module: &str) -> Vec<(PathBuf, &'static [syn::Item])> { + module_items(&self.tree, &self.arena, module).unwrap_or_default() + } +} + +/// 기본 옵션(심볼 레벨)으로 수확하고 트리·아레나까지 돌려준다. +pub(crate) fn harvest_parts(dir: &Path, meta: &Metadata) -> Result { + let opts = Options { + symbol_level: true, + ..Default::default() + }; + let mut sink = Sink { + spans: Some(Vec::new()), + want_tree: true, + ..Default::default() + }; + let (doc, _) = harvest(dir, meta, &opts, Some(&mut sink))?; + let (Some(tree), Some(arena)) = (sink.tree, sink.arena) else { + return Err("internal error: harvest did not return its module tree".to_string()); + }; + Ok(Parts { + doc, + spans: sink.spans.unwrap_or_default(), + tree, + arena, + }) } /// `--target`의 cfg 팩트 — `rustc --print cfg` 실측이 권위다. @@ -281,12 +337,12 @@ fn write_cache(path: &Path, key: u64, doc: &Document) { /// 실제 수확 — 메타데이터 위에서 모듈 트리·간선을 조립한다. /// (문서, include! 계열 매크로 사용 여부)를 돌린다 — include!는 /// 정점 위치에 안 나타나는 입력이라 캐시 판정이 별도로 필요하다. -/// `spans`가 주어지면 정점별 소스 범위(OwnerSpan)도 모은다. +/// `sink`가 주어지면 정점별 소스 범위(OwnerSpan)와 모듈 트리·아레나도 넘긴다. fn harvest( dir: &Path, meta: &Metadata, opts: &Options, - spans: Option<&mut Vec>, + mut sink: Option<&mut Sink>, ) -> Result<(Document, bool), String> { let mut harvest = Harvest::default(); let mut vertices: Vec = Vec::new(); @@ -454,7 +510,7 @@ fn harvest( attr_refs.extend(ar); bodies.extend(bs); } - if let Some(out) = spans { + if let Some(out) = sink.as_deref_mut().and_then(|k| k.spans.as_mut()) { collect_owner_spans(&tree, &impls, &bodies, item_spans, out); } @@ -589,6 +645,10 @@ fn harvest( } else { Level::Module }; + if let Some(k) = sink.filter(|k| k.want_tree) { + k.tree = Some(tree); + k.arena = Some(arena); + } Ok(( graph::document( level, @@ -820,14 +880,14 @@ fn parse_into(arena: &mut Arena, file: &Path, uses_include: &mut bool) -> Result /// 파일 모듈은 파일 AST(루트는 extra_files까지 — 같은 이름의 lib/bin이 /// 루트를 공유할 때 각 아이템의 실제 파일을 보존해야 semantic 엔진이 /// 본문 소유자를 올바른 소스에 맞춘다), 인라인 모듈은 조상의 mod 본문. -fn module_items<'a>( +fn module_items( tree: &ModTree, - arena: &'a Arena, + arena: &Arena, path: &str, -) -> Option> { +) -> Option> { let module = tree.modules.get(path)?; if module.file_module { - let mut out: Vec<(PathBuf, &'a [syn::Item])> = Vec::new(); + let mut out: Vec<(PathBuf, &'static [syn::Item])> = Vec::new(); if let Some(items) = arena.get(&module.file).copied() { out.push((module.file.clone(), items)); } @@ -847,11 +907,11 @@ fn module_items<'a>( } top = modtree::parent_of(&top)?; } - let mut items: &'a [syn::Item] = arena.get(&tree.modules[&top].file).copied()?; + let mut items: &'static [syn::Item] = arena.get(&tree.modules[&top].file).copied()?; // 파일 루트에서 목표까지 인라인 mod 본문을 따라 내려간다. let rel = path.strip_prefix(&format!("{top}::"))?; for seg in rel.split("::") { - let mut next: Option<&'a [syn::Item]> = None; + let mut next: Option<&'static [syn::Item]> = None; for it in items { if let syn::Item::Mod(m) = it { if m.ident == seg { diff --git a/src/source/routes.rs b/src/source/routes.rs new file mode 100644 index 0000000..d2498ba --- /dev/null +++ b/src/source/routes.rs @@ -0,0 +1,580 @@ +//! isthmus `bridge-facts` v1 http 도메인의 서버 측 생산자(`rustograph routes +//! --role server`) — axum·actix-web 라우트 선언을 `route-decl` 사실로 낸다. +//! +//! 계약의 정본은 ../isthmus의 docs/GRAPH-EXCHANGE.md "HTTP 경계"다. 프레임워크 +//! 규칙과 공식 소스 근거는 docs/HTTP-ROUTES.md에 있다. 흐름: +//! +//! 1. `cargo metadata` 해석 결과로 멤버 크레이트별 axum·actix-web 버전을 찾는다 +//! (Cargo.lock이 고정한 버전 = resolve의 패키지 버전). +//! 2. `impact`와 같은 syn 수확을 돌려 모듈 트리·AST·정점 집합을 얻는다 — +//! 핸들러 usr가 그래프 정점 ID와 같아야 `reach`로 이어진다. +//! 3. 프레임워크 추출기가 선언([`common::Decl`])과 공백([`common::Gap`])을 낸다. +//! 4. 이 모듈이 템플릿을 렌더링하고 usr·위치·스코프를 붙인 뒤 스스로 계약 +//! 검사(`order`·스코프·템플릿 문법)를 통과한 문서만 낸다. + +mod actix; +mod axum; +mod common; +mod pattern; +pub mod template; +pub mod validate; + +use crate::cargo_meta; +use crate::source::schema::{rfc3339_utc_now, BridgeFactsTool, BridgeLocation, FactSymbol}; +use common::{Anchor, Ctx, Decl, DeclPath, Gap, Handler, Output, ScopeSpec, Trailing}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use template::{constraints, render, template_problem, Seg}; + +/// 분석할 프레임워크다. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Framework { + Axum, + Actix, +} + +impl Framework { + /// `--framework` 값. + pub fn parse(s: &str) -> Option { + match s { + "axum" => Some(Framework::Axum), + "actix" | "actix-web" => Some(Framework::Actix), + _ => None, + } + } +} + +/// `routes` 옵션. +#[derive(Debug, Default)] +pub struct RouteOptions { + /// 워크스페이스에 두 프레임워크가 모두 있을 때 고른 하나. + pub framework: Option, +} + +/// http 문서의 route-decl 사실이다. 키 순서는 계약 문서 나열 순서를 따른다. +#[derive(Serialize)] +pub struct RouteFact { + pub kind: &'static str, + pub method: String, + pub channel: Option, + pub dynamic: bool, + #[serde(rename = "pathAnchor")] + pub path_anchor: &'static str, + #[serde(rename = "trailingSlash", skip_serializing_if = "Option::is_none")] + pub trailing_slash: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub narrowed: Option, + #[serde(rename = "paramConstraints", skip_serializing_if = "Vec::is_empty")] + pub param_constraints: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + pub location: BridgeLocation, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, +} + +/// `paramConstraints` 항목. +#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +pub struct ParamConstraint { + pub segment: usize, + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, +} + +/// registration-order 문서의 `order`. +#[derive(Serialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Order { + pub group: String, + pub index: u64, +} + +/// http `limitationScopes` 항목. +#[derive(Serialize, Debug, Clone)] +pub struct LimitationScope { + #[serde(rename = "limitationIndex")] + pub limitation_index: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub templates: Vec, + #[serde(rename = "templatePrefixes", skip_serializing_if = "Vec::is_empty")] + pub template_prefixes: Vec, + #[serde(rename = "templateSuffixes", skip_serializing_if = "Vec::is_empty")] + pub template_suffixes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub methods: Vec, +} + +/// 문서의 `sourceSets`. +#[derive(Serialize, Debug)] +pub struct SourceSets { + pub tests: &'static str, +} + +/// isthmus bridge-facts v1 http 서버 문서다. +#[derive(Serialize)] +pub struct RoutesDocument { + pub format: &'static str, + pub version: u8, + pub tool: BridgeFactsTool, + #[serde(rename = "generatedAt")] + pub generated_at: String, + pub platform: &'static str, + /// roles가 있는 http 문서는 사실 0건이어도 `http`다(계약의 http 예외). + pub target: &'static str, + pub project: String, + pub roles: Vec<&'static str>, + pub dispatch: &'static str, + #[serde(rename = "sourceSets")] + pub source_sets: SourceSets, + pub facts: Vec, + pub limitations: Vec, + #[serde(rename = "limitationScopes", skip_serializing_if = "Vec::is_empty")] + pub limitation_scopes: Vec, +} + +/// 멤버 크레이트 하나의 프레임워크 사용. +struct Use { + /// 수확 트리의 루트 모듈 이름(lib·bin 타깃 이름). + roots: Vec, + framework: Framework, + version: String, +} + +/// 워크스페이스의 axum·actix-web 라우트를 http 서버 문서로 낸다. +/// +/// 두 프레임워크가 모두 있는데 `--framework`가 없으면 사용법 오류다 — 문서 +/// 하나는 dispatch 하나만 선언할 수 있다. +pub fn routes( + dir: &Path, + tool_version: &str, + opts: &RouteOptions, +) -> Result { + let root = dir + .canonicalize() + .map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?; + let meta = cargo_meta::load(dir)?; + let uses = framework_uses(&meta); + let present: BTreeSet = uses.iter().map(|u| u.framework).collect(); + let framework = match (opts.framework, present.len()) { + (Some(f), _) => Some(f), + (None, 0) => None, + (None, 1) => present.iter().next().copied(), + (None, _) => { + return Err( + "the workspace depends on both axum and actix-web; pass --framework axum or --framework actix" + .to_string(), + ) + } + }; + let mut out = Output::default(); + let dispatch = match framework { + Some(Framework::Actix) => "registration-order", + _ => "specificity", + }; + match framework { + None => out.gap( + "route-coverage:", + "no workspace member depends on axum or actix-web, so no routes were extracted" + .to_string(), + ), + Some(f) => { + let parts = crate::source::harvest_parts(dir, &meta)?; + let ctx = Ctx::new(&parts, root.clone()); + for u in uses.iter().filter(|u| u.framework == f) { + extract_use(&ctx, u, &mut out); + } + if !uses.iter().any(|u| u.framework == f) { + out.gap( + "route-coverage:", + format!( + "--framework {f:?} was requested but no workspace member depends on it" + ), + ); + } + return assemble(&ctx, out, dispatch, tool_version, &root, &meta); + } + } + finish(out, dispatch, tool_version, &root, &meta, Vec::new()) +} + +/// 멤버 하나의 루트들에서 추출한다. +fn extract_use(ctx: &Ctx, u: &Use, out: &mut Output) { + match u.framework { + Framework::Axum => { + let version = match u.version.split('.').collect::>().as_slice() { + ["0", "7", ..] => axum::Version::V07, + ["0", "8", ..] => axum::Version::V08, + ["0", "6", ..] => { + version_gap(out, "axum", &u.version, "0.7 path syntax"); + axum::Version::V07 + } + _ => { + version_gap(out, "axum", &u.version, "0.8 path syntax"); + axum::Version::V08 + } + }; + for r in &u.roots { + axum::extract(ctx, r, version, out); + } + } + Framework::Actix => { + if !u.version.starts_with("4.") { + version_gap(out, "actix-web", &u.version, "actix-web 4 rules"); + } + for r in &u.roots { + actix::extract(ctx, r, out); + } + } + } +} + +/// 확인한 메이저 밖의 버전 — 규칙이 다를 수 있다는 서버 측 공백. +fn version_gap(out: &mut Output, name: &str, version: &str, assumed: &str) { + out.gap( + "route-framework-version-unknown:", + format!( + "{name} {version} is outside the verified versions; routes were read with {assumed}" + ), + ); +} + +/// 멤버 크레이트가 직접 의존하는 axum·actix-web과 그 해석 버전이다. +fn framework_uses(meta: &cargo_meta::Metadata) -> Vec { + let mut out = Vec::new(); + for d in meta.dep_edges.iter().filter(|d| d.kind.is_empty()) { + let (Some(&fi), Some(&ti)) = (meta.by_id.get(&d.from), meta.by_id.get(&d.to)) else { + continue; + }; + let (from, to) = (&meta.packages[fi], &meta.packages[ti]); + if !from.workspace_member { + continue; + } + let framework = match to.name.as_str() { + "axum" => Framework::Axum, + "actix_web" => Framework::Actix, + _ => continue, + }; + let roots: Vec = from + .targets + .iter() + .filter(|t| matches!(t.kind.as_str(), "lib" | "bin")) + .map(|t| t.name.clone()) + .collect::>() + .into_iter() + .collect(); + out.push(Use { + roots, + framework, + version: to.version.clone(), + }); + } + out +} + +/// 선언을 사실로, 공백을 한계로 조립하고 스스로 계약 검사를 한다. +fn assemble( + ctx: &Ctx, + out: Output, + dispatch: &'static str, + tool_version: &str, + root: &Path, + meta: &cargo_meta::Metadata, +) -> Result { + let mut facts = Vec::new(); + let mut gaps = out.gaps; + let mut closures = 0usize; + let mut unknown = 0usize; + let mut unlocated = 0usize; + // 같은 라우터가 두 번 서빙되거나 두 루트가 같은 함수를 부르면 같은 선언이 + // 거듭 나온다 — 한 번만 센다. + let mut seen: BTreeSet = BTreeSet::new(); + for d in &out.decls { + if !seen.insert(decl_key(d)) { + continue; + } + let Some(location) = ctx.locate(&d.loc) else { + unlocated += 1; + continue; + }; + let symbol = match &d.handler { + Handler::Usr(id) => Some(id.clone()), + // 감싸는 정점을 찾은 클로저만 근사 usr 한계로 센다 — 못 찾으면 usr가 없는 + // 핸들러와 같다. + Handler::Closure(l) => match ctx.owner_of(l) { + Some(id) => { + closures += 1; + Some(id) + } + None => { + unknown += 1; + None + } + }, + Handler::Unknown => { + unknown += 1; + None + } + } + .map(|id| FactSymbol { + qualified_name: id.clone(), + usr: id, + }); + for fact in decl_facts(d, dispatch, &location, symbol.as_ref()) { + facts.push(fact); + } + } + if closures > 0 { + gaps.push(Gap { + prefix: "missing-route-usrs:", + text: format!("{closures} route handler(s) are closures; their symbol.usr names the enclosing function, so forward reach from those routes over-approximates"), + scope: None, + }); + } + if unknown > 0 { + gaps.push(Gap { + prefix: "missing-route-usrs:", + text: format!("{unknown} route declaration(s) have a handler that is not a workspace function (a tower service, a redirect, or an unresolved path); they carry no symbol"), + scope: None, + }); + } + if unlocated > 0 { + gaps.push(Gap { + prefix: "route-coverage:", + text: format!( + "{unlocated} route declaration(s) had no source location and were not emitted" + ), + scope: None, + }); + } + finish( + Output { + decls: Vec::new(), + gaps, + }, + dispatch, + tool_version, + root, + meta, + facts, + ) +} + +/// 사실·한계를 정렬하고 문서를 만든 뒤 계약 검사를 한다. +fn finish( + out: Output, + dispatch: &'static str, + tool_version: &str, + root: &Path, + meta: &cargo_meta::Metadata, + mut facts: Vec, +) -> Result { + facts.sort_by(fact_key); + facts.dedup_by(|a, b| fact_key(a, b).is_eq()); + let (limitations, limitation_scopes) = limitations(out.gaps, &meta.limitations); + let doc = RoutesDocument { + format: "bridge-facts", + version: 1, + tool: BridgeFactsTool { + name: "rustograph", + version: tool_version.to_string(), + }, + generated_at: rfc3339_utc_now(), + platform: "rust", + target: "http", + project: root.display().to_string(), + roles: vec!["server"], + dispatch, + source_sets: SourceSets { tests: "excluded" }, + facts, + limitations, + limitation_scopes, + }; + self_check(&doc)?; + Ok(doc) +} + +/// 선언 하나를 method·변형별 사실로 편다. +fn decl_facts( + d: &Decl, + dispatch: &str, + location: &BridgeLocation, + symbol: Option<&FactSymbol>, +) -> Vec { + let mut variants: Vec<(Option, Vec, Option<&'static str>)> = + Vec::new(); + let dynamic = match &d.path { + DeclPath::Template { segs, empty_tail } => { + let slash = trailing_str(d.trailing); + variants.push((Some(render(segs)), param_constraints(segs), slash)); + if let Some(tail) = empty_tail { + // 빈 값 변형 — catch-all 자리를 빈 세그먼트로 채운 정적 템플릿이다. + let mut empty = segs.clone(); + if let Some(last) = empty.last_mut() { + *last = Seg::Lit(String::new()); + } + variants.push(( + Some(render(&empty)), + param_constraints(&empty), + trailing_str(*tail), + )); + } + false + } + DeclPath::Dynamic(text) => { + variants.push((Some(text.clone()), Vec::new(), None)); + true + } + }; + let order = if dispatch == "registration-order" { + d.order.as_ref().map(|(g, i)| Order { + group: g.clone(), + index: *i, + }) + } else { + None + }; + let mut out = Vec::new(); + for method in &d.methods { + for (channel, pc, slash) in &variants { + out.push(RouteFact { + kind: "route-decl", + method: method.clone(), + channel: channel.clone(), + dynamic, + path_anchor: match d.anchor { + Anchor::Root => "root", + Anchor::Base => "base", + }, + trailing_slash: *slash, + narrowed: d.narrowed.then_some(true), + param_constraints: pc.clone(), + order: order.clone(), + location: location.clone(), + symbol: symbol.cloned(), + }); + } + } + out +} + +/// 선언 중복 판정 키 — 위치·method·경로·앵커·순서·핸들러. +fn decl_key(d: &Decl) -> String { + let path = match &d.path { + DeclPath::Template { segs, .. } => render(segs), + DeclPath::Dynamic(t) => format!("dyn:{t}"), + }; + format!( + "{}|{:?}|{:?}|{path}|{:?}|{:?}|{:?}", + d.loc.file.display(), + d.loc.span.byte_range(), + d.methods, + d.anchor, + d.order, + d.handler + ) +} + +/// 끝 슬래시 판정의 계약 값. +fn trailing_str(t: Trailing) -> Option<&'static str> { + match t { + Trailing::Strict => Some("strict"), + Trailing::Optional => Some("optional"), + Trailing::Unknown => None, + } +} + +/// 세그먼트의 `paramConstraints`. +fn param_constraints(segs: &[Seg]) -> Vec { + constraints(segs) + .into_iter() + .map(|(segment, c)| ParamConstraint { + segment, + kind: c.kind(), + pattern: match c { + template::Constraint::Regex(p) => Some(p), + _ => None, + }, + }) + .collect() +} + +/// 사실의 결정적 정렬 키. +fn fact_key(a: &RouteFact, b: &RouteFact) -> std::cmp::Ordering { + let key = |f: &RouteFact| { + ( + f.channel.clone(), + f.method.clone(), + f.path_anchor, + f.order.clone(), + f.location.path.clone(), + f.location.line, + f.location.column, + f.symbol.as_ref().map(|s| s.usr.clone()), + f.narrowed, + ) + }; + key(a).cmp(&key(b)) +} + +/// 공백을 한계 문장과 스코프로 바꾼다. 같은 문장·스코프는 하나로 합친다. 스코프가 +/// 계약 검사를 통과하지 못하면 스코프 없이(문서 전체 효과로) 남긴다. +fn limitations(gaps: Vec, meta_limits: &[String]) -> (Vec, Vec) { + let mut seen: BTreeMap<(String, Option), ()> = BTreeMap::new(); + let mut ordered: Vec<(String, Option)> = Vec::new(); + for g in gaps { + let text = format!("{} {}", g.prefix, g.text); + let key = (text, g.scope); + if seen.insert(key.clone(), ()).is_none() { + ordered.push(key); + } + } + ordered.sort(); + let mut lines: Vec = Vec::new(); + let mut scopes: Vec = Vec::new(); + for (text, scope) in ordered { + let index = lines.len(); + lines.push(text); + if let Some(s) = scope { + let entry = LimitationScope { + limitation_index: index, + templates: s.templates, + template_prefixes: s.prefixes, + template_suffixes: s.suffixes, + methods: s.methods, + }; + let value = serde_json::to_value(&entry).unwrap_or_default(); + if validate::scope_problem(&value).is_none() { + scopes.push(entry); + } + } + } + // cargo metadata 한계(resolve 없음 등)는 버전 판정의 근거가 약해진다는 뜻이다. + for l in meta_limits { + lines.push(format!("route-framework-version-unknown: {l}")); + } + (lines, scopes) +} + +/// 내기 전에 템플릿 문법·order 규칙을 소비자와 같은 검사로 확인한다. 실패는 +/// 생산자 결함이므로 문서를 내지 않고 오류로 보고한다. +fn self_check(doc: &RoutesDocument) -> Result<(), String> { + for (i, f) in doc.facts.iter().enumerate() { + if f.dynamic { + continue; + } + let Some(channel) = &f.channel else { continue }; + if let Some(problem) = template_problem(channel) { + return Err(format!( + "internal error: route template {channel:?} at fact {i} is not canonical ({problem}); please report this with the route source" + )); + } + } + let value = serde_json::to_value(doc).map_err(|e| format!("internal error: {e}"))?; + if let Some(problem) = validate::order_problem(&value) { + return Err(format!( + "internal error: the routes document breaks the isthmus order contract ({problem}); please report this" + )); + } + Ok(()) +} diff --git a/src/source/routes/actix.rs b/src/source/routes/actix.rs new file mode 100644 index 0000000..e357154 --- /dev/null +++ b/src/source/routes/actix.rs @@ -0,0 +1,1515 @@ +//! actix-web 4 라우트 선언 추출. +//! +//! 규칙과 근거(actix-web 4.15.0·actix-router 0.5.4·actix-web-codegen 4.4.0 소스 +//! 줄 번호)는 docs/HTTP-ROUTES.md에 있다. 요약: +//! +//! - App·Scope 라우터는 서비스를 등록 순서대로 시도하고 첫 매치가 이긴다(정렬 +//! 없음, app_service.rs:302-311·router.rs:54-58) → `dispatch: "registration-order"`. +//! 리소스 하나가 등록 하나(`order.index`)이고 App마다 group 하나다. +//! - `#[get]` 매크로·`App::route`·`Scope::route`는 method 가드를 리소스 수준에 +//! 붙여(route.rs:456-463, app.rs:227-233) method가 안 맞으면 다음 리소스로 +//! 넘어간다. `web::resource().route(web::get())`은 라우트 수준이라 405다. +//! - 스코프는 접두사를 소비하고 안쪽은 나머지로 정확 매치한다 — 결합은 문자열 +//! 연결과 같다(resource.rs:503-519 `join`, introspection.rs:576-594). +//! - 끝 슬래시는 엄격하고 App·Scope의 `NormalizePath` 미들웨어가 바꾼다. + +use super::common::{ + handler_ref, is_api, order_group, pat_ident, path_arg, prefix_template, str_lit, + unevaluated_constructors, Anchor, Ctx, Decl, DeclPath, FnSite, Handler, HandlerRef, Imports, + JPath, Loc, Output, PathArg, ScopeSpec, Trailing, +}; +use super::pattern::parse_actix; +use super::template::{render, Seg}; +use crate::harvest::path_segments; +use std::collections::{BTreeMap, BTreeSet}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::visit::Visit; + +/// method 매크로 이름과 동사(actix-web-codegen route.rs:94-104). +const METHOD_MACROS: &[(&str, &str)] = &[ + ("get", "GET"), + ("post", "POST"), + ("put", "PUT"), + ("delete", "DELETE"), + ("head", "HEAD"), + ("connect", "CONNECT"), + ("options", "OPTIONS"), + ("trace", "TRACE"), + ("patch", "PATCH"), +]; + +/// isthmus가 받는 동사(CONNECT·사용자 동사는 http 도메인에 없다). +const HTTP_METHODS: &[&str] = &[ + "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", +]; + +/// 한 크레이트의 actix-web 라우트를 추출한다. +pub(super) fn extract(ctx: &Ctx, krate: &str, out: &mut Output) { + let fns = ctx.crate_fns(krate); + let mut ev = Eval { + ctx, + fns: &fns, + imports: BTreeMap::new(), + macros: BTreeMap::new(), + registered: BTreeSet::new(), + memo: BTreeMap::new(), + stack: Vec::new(), + out_gaps: Vec::new(), + }; + for site in fns.values() { + let regs = ev.macro_regs(site); + if !regs.is_empty() { + ev.macros.insert(site.id.clone(), regs); + } + } + let mut apps: Vec<(String, AppVal)> = Vec::new(); + for site in fns.values() { + let found = ev.apps_in(site); + let many = found.len() > 1; + for (i, app) in found.into_iter().enumerate() { + let id = if many && i > 0 { + format!("{}#{}", site.id, i + 1) + } else { + site.id.clone() + }; + let group = order_group("actix:", &id); + apps.push((group, app)); + } + } + let gaps = std::mem::take(&mut ev.out_gaps); + out.gaps.extend(gaps); + for loc in unevaluated_constructors(ctx, krate, "actix_web", &["App", "new"]) { + let at = ctx + .locate(&loc) + .map(|l| format!("{}:{}", l.path, l.line)) + .unwrap_or_default(); + out.gap( + "route-coverage:", + format!("an App built inside a method at {at} is not evaluated; its routes are not extracted"), + ); + } + let macros = std::mem::take(&mut ev.macros); + let mut emit = Emit { + ctx, + out, + macros: ¯os, + bases: Vec::new(), + }; + for (group, app) in &apps { + let mut index = 0u64; + emit.services( + &app.services, + &JPath::Lit(String::new()), + &Guard::default(), + app.normalize, + Some((group.as_str(), &mut index)), + ); + for loc in &app.defaults { + emit.out.gap( + "route-coverage:", + format!( + "an App default service at {} receives requests that no route matches", + emit.at(loc) + ), + ); + } + } + // 어느 App에도 등록되지 않은 매크로 핸들러 — 어디에 붙는지 모른다. + let unregistered: Vec<(String, Vec)> = macros + .iter() + .filter(|(id, _)| !ev.registered.contains(*id)) + .map(|(id, regs)| (id.clone(), regs.clone())) + .collect(); + for (id, regs) in &unregistered { + for reg in regs { + emit.resource_decls( + std::slice::from_ref(®.path), + ®.loc, + &Guard { + methods: reg.methods.clone(), + narrowed: reg.narrowed, + }, + &[RouteVal::any(HandlerRef::Unknown)], + &JPath::Dyn(reg.loc.clone()), + Normalize::None, + None, + Some(Handler::Usr(id.clone())), + ); + } + } + emit.finish_bases(); +} + +/// `NormalizePath` 모드(normalize.rs:44-50, 174-228). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Normalize { + #[default] + None, + Trim, + Always, + MergeOnly, + Unknown, +} + +/// 가드 요약 — method 제한(None = 모두)과 그 밖의 조건(narrowed). +#[derive(Clone, Debug, Default)] +struct Guard { + methods: Option>, + narrowed: bool, +} + +impl Guard { + /// 두 가드를 모두 통과해야 한다(교집합). + fn and(&self, o: &Guard) -> Guard { + let methods = match (&self.methods, &o.methods) { + (None, m) | (m, None) => m.clone(), + (Some(a), Some(b)) => Some(a.intersection(b).cloned().collect()), + }; + Guard { + methods, + narrowed: self.narrowed || o.narrowed, + } + } +} + +/// 매크로 등록 하나(`#[get("/x")]` 하나 = 리소스 하나). +#[derive(Clone, Debug)] +struct MacroReg { + path: PathArg, + loc: Loc, + methods: Option>, + narrowed: bool, +} + +/// 라우트 하나 — 라우트 수준 가드와 핸들러. +#[derive(Clone, Debug)] +struct RouteVal { + guard: Guard, + handler: HandlerRef, +} + +impl RouteVal { + fn any(handler: HandlerRef) -> RouteVal { + RouteVal { + guard: Guard::default(), + handler, + } + } +} + +/// 리소스 — 경로(여러 개일 수 있다), 리소스 수준 가드, 라우트들. +#[derive(Clone, Debug)] +struct ResourceVal { + paths: Vec, + loc: Loc, + guard: Guard, + routes: Vec, +} + +/// 스코프 — 접두사, 가드, 안쪽 서비스, 정규화, 기본 서비스. +#[derive(Clone, Debug)] +struct ScopeVal { + prefix: PathArg, + guard: Guard, + services: Vec, + normalize: Option, + defaults: Vec, +} + +/// 등록된 서비스 하나. +#[derive(Clone, Debug)] +enum Svc { + Resource(ResourceVal), + Scope(ScopeVal), + Macro(String), + Files { prefix: PathArg }, + Unknown { loc: Loc, what: String }, +} + +/// App 값. +#[derive(Clone, Debug, Default)] +struct AppVal { + services: Vec, + normalize: Normalize, + defaults: Vec, +} + +/// 평가 값. +#[derive(Clone, Debug)] +enum Val { + App(AppVal), + Scope(ScopeVal), + Resource(ResourceVal), + Route(RouteVal), + Files(PathArg), +} + +/// 평가기. +struct Eval<'a> { + ctx: &'a Ctx<'a>, + fns: &'a BTreeMap, + imports: BTreeMap, + macros: BTreeMap>, + registered: BTreeSet, + memo: BTreeMap>, + stack: Vec, + out_gaps: Vec, +} + +impl Eval<'_> { + fn imports(&mut self, module: &str) -> &Imports { + if !self.imports.contains_key(module) { + let groups = self.ctx.parts.module_items(module); + self.imports + .insert(module.to_string(), Imports::of(&groups)); + } + &self.imports[module] + } + + fn is_actix(&mut self, module: &str, segs: &[String], tail: &[&str]) -> bool { + is_api(self.imports(module), segs, "actix_web", tail) + } + + /// 함수에 붙은 라우트 매크로 등록들(`#[routes]`면 속성 순서대로 여럿). + fn macro_regs(&mut self, site: &FnSite) -> Vec { + let mut regs = Vec::new(); + // `#[routes]` 아래의 method 속성은 그 매크로가 읽는 도우미라 import가 없어도 + // 된다(codegen route.rs:509-550) — actix `routes`가 붙었으면 이름만으로 받는다. + let under_routes = site.item.attrs.iter().any(|a| { + let segs = path_segments(a.path()); + segs.last().is_some_and(|l| l == "routes") + && self.is_actix(&site.module, &segs, &["routes"]) + }); + for attr in &site.item.attrs { + let segs = path_segments(attr.path()); + let Some(name) = segs.last().cloned() else { + continue; + }; + let verb = METHOD_MACROS + .iter() + .find(|(m, _)| *m == name) + .map(|(_, v)| v.to_string()); + if verb.is_none() && name != "route" { + continue; + } + let helper = under_routes && verb.is_some() && segs.len() == 1; + if !helper && !self.is_actix(&site.module, &segs, &[name.as_str()]) { + continue; + } + match parse_macro(site, attr, verb) { + Ok(reg) => regs.push(reg), + Err(why) => self.out_gaps.push(super::common::Gap { + prefix: "route-coverage:", + text: format!("route macro on {} {why}", site.id), + scope: None, + }), + } + } + regs + } + + /// 함수 본문의 App 체인을 찾아 평가한다(`App::new()`에서 시작하는 가장 바깥 체인). + fn apps_in(&mut self, site: &FnSite) -> Vec { + struct Finder<'b> { + imports: &'b Imports, + /// 지역 묶음·재대입으로 이미 평가한 체인(바이트 범위) — 다시 루트로 세지 않는다. + handled: &'b BTreeSet<(usize, usize)>, + chains: Vec, + /// 함수·메서드 인자로 넘어간 App 체인의 위치. + passed: Vec, + } + impl Finder<'_> { + fn is_app(&self, e: &syn::Expr) -> bool { + chain_root_is(self.imports, e, &["App", "new"]) + } + } + impl Visit<'_> for Finder<'_> { + fn visit_expr(&mut self, e: &syn::Expr) { + if self.is_app(e) { + let r = e.span().byte_range(); + if !self.handled.contains(&(r.start, r.end)) { + self.chains.push(e.clone()); + } + // 인자 안의 App은 없다고 본다 — 체인 안으로 내려가지 않는다. + return; + } + syn::visit::visit_expr(self, e); + } + fn visit_expr_call(&mut self, c: &syn::ExprCall) { + let spans: Vec<_> = c + .args + .iter() + .filter(|a| self.is_app(a)) + .map(|a| a.span()) + .collect(); + self.passed.extend(spans); + syn::visit::visit_expr_call(self, c); + } + fn visit_expr_method_call(&mut self, m: &syn::ExprMethodCall) { + let spans: Vec<_> = m + .args + .iter() + .filter(|a| self.is_app(a)) + .map(|a| a.span()) + .collect(); + self.passed.extend(spans); + syn::visit::visit_expr_method_call(self, m); + } + } + let groups = self.ctx.parts.module_items(&site.module); + let imports = Imports::of(&groups); + // 1) 본문 최상위의 `let app = App::new()…;`와 `app = app.route(..);` 재대입을 따라간다. + let mut env: BTreeMap = BTreeMap::new(); + let mut handled: BTreeSet<(usize, usize)> = BTreeSet::new(); + for stmt in &site.item.block.stmts { + let (name, init) = match stmt { + syn::Stmt::Local(l) => match (pat_ident(&l.pat), &l.init) { + (Some(n), Some(i)) => (n, &*i.expr), + _ => continue, + }, + syn::Stmt::Expr(syn::Expr::Assign(a), _) => match &*a.left { + syn::Expr::Path(p) => match p.path.get_ident() { + Some(n) if env.contains_key(&n.to_string()) => (n.to_string(), &*a.right), + _ => continue, + }, + _ => continue, + }, + _ => continue, + }; + let rooted_here = chain_root_is(&imports, init, &["App", "new"]); + let from_env = chain_root_ident(init).is_some_and(|r| env.contains_key(&r)); + if !rooted_here && !from_env { + continue; + } + if let Some(v @ Val::App(_)) = self.eval(site, &env, init) { + let r = init.span().byte_range(); + handled.insert((r.start, r.end)); + env.insert(name, v); + } + } + // 2) 그 밖의 App 체인(클로저 안의 `App::new()…` 등). + let mut f = Finder { + imports: &imports, + handled: &handled, + chains: Vec::new(), + passed: Vec::new(), + }; + f.visit_block(&site.item.block); + for span in &f.passed { + let loc = Loc { + file: site.file.clone(), + span: *span, + }; + let at = self + .ctx + .locate(&loc) + .map(|l| format!("{}:{}", l.path, l.line)) + .unwrap_or_default(); + self.out_gaps.push(super::common::Gap { + prefix: "route-coverage:", + text: format!( + "an App passed to a function at {at} may receive more registrations there" + ), + scope: None, + }); + } + let empty = BTreeMap::new(); + let mut apps: Vec = f + .chains + .iter() + .filter_map(|e| match self.eval(site, &empty, e) { + Some(Val::App(a)) => Some(a), + _ => None, + }) + .collect(); + apps.extend(env.into_values().filter_map(|v| match v { + Val::App(a) => Some(a), + _ => None, + })); + apps + } + + /// 크레이트 함수의 반환 값(스코프·리소스 등)을 평가한다. + fn eval_fn(&mut self, id: &str) -> Option { + if let Some(v) = self.memo.get(id) { + return v.clone(); + } + if self.stack.iter().any(|s| s == id) { + return None; + } + let site = self.fns.get(id)?; + self.stack.push(id.to_string()); + let mut env = BTreeMap::new(); + let mut result = None; + for stmt in &site.item.block.stmts { + match stmt { + syn::Stmt::Local(l) => { + if let (Some(name), Some(init)) = (pat_ident(&l.pat), &l.init) { + if let Some(v) = self.eval(site, &env, &init.expr) { + env.insert(name, v); + } + } + } + syn::Stmt::Expr(syn::Expr::Return(r), _) => { + result = r.expr.as_ref().and_then(|e| self.eval(site, &env, e)); + break; + } + syn::Stmt::Expr(e, None) => result = self.eval(site, &env, e), + _ => {} + } + } + self.stack.pop(); + self.memo.insert(id.to_string(), result.clone()); + result + } + + /// 식을 평가한다. + fn eval(&mut self, site: &FnSite, env: &BTreeMap, e: &syn::Expr) -> Option { + match e { + syn::Expr::Paren(p) => self.eval(site, env, &p.expr), + syn::Expr::Group(g) => self.eval(site, env, &g.expr), + syn::Expr::Path(p) => env.get(&p.path.get_ident()?.to_string()).cloned(), + syn::Expr::Call(c) => self.eval_call(site, env, c), + syn::Expr::MethodCall(m) => { + let recv = self.eval(site, env, &m.receiver)?; + let args: Vec<&syn::Expr> = m.args.iter().collect(); + Some(self.apply(site, env, recv, &m.method.to_string(), &args, m.span())) + } + _ => None, + } + } + + /// 생성 호출 — App·Scope·Resource·Route 생성자와 크레이트 함수. + fn eval_call( + &mut self, + site: &FnSite, + env: &BTreeMap, + c: &syn::ExprCall, + ) -> Option { + let syn::Expr::Path(p) = &*c.func else { + return None; + }; + let segs = path_segments(&p.path); + let module = site.module.clone(); + if let Some(id) = self.ctx.resolve_vertex(&module, &segs) { + if self.fns.contains_key(&id) { + if let Some(v) = self.eval_fn(&id) { + return Some(v); + } + } + } + let args: Vec<&syn::Expr> = c.args.iter().collect(); + let loc = Loc { + file: site.file.clone(), + span: c.span(), + }; + let pa = |ev: &mut Self, e: &syn::Expr| path_arg(ev.ctx, &site.module, &site.file, e); + if self.is_actix(&module, &segs, &["App", "new"]) { + return Some(Val::App(AppVal::default())); + } + if self.is_actix(&module, &segs, &["scope"]) + || self.is_actix(&module, &segs, &["Scope", "new"]) + { + let [prefix] = args.as_slice() else { + return None; + }; + return Some(Val::Scope(ScopeVal { + prefix: pa(self, prefix), + guard: Guard::default(), + services: Vec::new(), + normalize: None, + defaults: Vec::new(), + })); + } + if self.is_actix(&module, &segs, &["resource"]) + || self.is_actix(&module, &segs, &["Resource", "new"]) + || self.is_actix(&module, &segs, &["service"]) + { + let [paths] = args.as_slice() else { + return None; + }; + return Some(Val::Resource(ResourceVal { + paths: self.resource_paths(site, paths), + loc: Loc { + file: site.file.clone(), + span: paths.span(), + }, + guard: Guard::default(), + routes: Vec::new(), + })); + } + if self.is_actix(&module, &segs, &["redirect"]) + || self.is_actix(&module, &segs, &["Redirect", "new"]) + { + let [from, _] = args.as_slice() else { + return None; + }; + return Some(Val::Resource(ResourceVal { + paths: vec![pa(self, from)], + loc: Loc { + file: site.file.clone(), + span: from.span(), + }, + guard: Guard::default(), + routes: vec![RouteVal::any(HandlerRef::Unknown)], + })); + } + if is_api( + self.imports(&module), + &segs, + "actix_files", + &["Files", "new"], + ) { + let [prefix, _] = args.as_slice() else { + return None; + }; + return Some(Val::Files(pa(self, prefix))); + } + let name = segs.last()?.clone(); + if let Some((_, verb)) = METHOD_MACROS.iter().find(|(m, _)| *m == name) { + if self.is_actix(&module, &segs, &["web", name.as_str()]) && args.is_empty() { + return Some(Val::Route(RouteVal { + guard: Guard { + methods: Some([verb.to_string()].into()), + narrowed: false, + }, + handler: HandlerRef::Unknown, + })); + } + } + if self.is_actix(&module, &segs, &["web", "route"]) + || self.is_actix(&module, &segs, &["Route", "new"]) + { + return Some(Val::Route(RouteVal::any(HandlerRef::Unknown))); + } + if self.is_actix(&module, &segs, &["web", "method"]) { + let [m] = args.as_slice() else { return None }; + return Some(Val::Route(RouteVal { + guard: method_guard(m), + handler: HandlerRef::Unknown, + })); + } + if self.is_actix(&module, &segs, &["web", "to"]) { + let [h] = args.as_slice() else { return None }; + return Some(Val::Route(RouteVal::any(handler_ref( + &site.module, + &site.file, + h, + )))); + } + let _ = (env, loc); + None + } + + /// `web::resource`의 경로 — 리터럴 하나 또는 배열·`vec!` 원소들. + fn resource_paths(&mut self, site: &FnSite, e: &syn::Expr) -> Vec { + let elems: Vec<&syn::Expr> = match e { + syn::Expr::Array(a) => a.elems.iter().collect(), + syn::Expr::Reference(r) => return self.resource_paths(site, &r.expr), + _ => vec![e], + }; + elems + .into_iter() + .map(|x| path_arg(self.ctx, &site.module, &site.file, x)) + .collect() + } + + /// 체인 메서드를 값에 적용한다. 모르는 메서드는 투명하다(`wrap`·`app_data` 등). + fn apply( + &mut self, + site: &FnSite, + env: &BTreeMap, + recv: Val, + name: &str, + args: &[&syn::Expr], + span: proc_macro2::Span, + ) -> Val { + let loc = Loc { + file: site.file.clone(), + span, + }; + match recv { + Val::App(mut a) => { + match (name, args) { + ("service", [x]) => { + let svcs = self.services_of(site, env, x); + a.services.extend(svcs); + } + ("route", [p, r]) => a.services.push(self.route_resource(site, env, p, r)), + ("configure", [f]) => { + let (svcs, defaults) = self.configure(site, f); + a.services.extend(svcs); + a.defaults.extend(defaults); + } + ("default_service", [_]) => a.defaults.push(loc), + ("wrap", [mw]) => { + if let Some(n) = normalize_of(mw) { + a.normalize = n; + } + } + _ => {} + } + Val::App(a) + } + Val::Scope(mut s) => { + match (name, args) { + ("service", [x]) => { + let svcs = self.services_of(site, env, x); + s.services.extend(svcs); + } + ("route", [p, r]) => s.services.push(self.route_resource(site, env, p, r)), + ("configure", [f]) => { + let (svcs, defaults) = self.configure(site, f); + s.services.extend(svcs); + s.defaults.extend(defaults); + } + ("default_service", [_]) => s.defaults.push(loc), + ("guard", [g]) => s.guard = s.guard.and(&guard_of(g)), + ("wrap", [mw]) => { + if let Some(n) = normalize_of(mw) { + s.normalize = Some(n); + } + } + _ => {} + } + Val::Scope(s) + } + Val::Resource(mut r) => { + match (name, args) { + ("route", [x]) => match self.eval(site, env, x) { + Some(Val::Route(rv)) => r.routes.push(rv), + _ => r.routes.push(RouteVal::any(HandlerRef::Unknown)), + }, + ("to" | "finish", [h]) => { + r.routes + .push(RouteVal::any(handler_ref(&site.module, &site.file, h))) + } + ("default_service", [x]) => r.routes.push(self.default_route(site, env, x)), + ("guard", [g]) => r.guard = r.guard.and(&guard_of(g)), + (m, [h]) => { + if let Some((_, verb)) = METHOD_MACROS + .iter() + .find(|(n, _)| *n == m && *n != "connect" && *n != "options") + { + r.routes.push(RouteVal { + guard: Guard { + methods: Some([verb.to_string()].into()), + narrowed: false, + }, + handler: handler_ref(&site.module, &site.file, h), + }); + } + } + _ => {} + } + Val::Resource(r) + } + Val::Route(mut rv) => { + match (name, args) { + ("to", [h]) => rv.handler = handler_ref(&site.module, &site.file, h), + ("method", [m]) => rv.guard = rv.guard.and(&method_guard(m)), + ("guard", [g]) => rv.guard = rv.guard.and(&guard_of(g)), + _ => {} + } + Val::Route(rv) + } + other => other, + } + } + + /// `default_service(web::to(h))`처럼 핸들러가 보이면 그 핸들러의 ANY 라우트. + fn default_route( + &mut self, + site: &FnSite, + env: &BTreeMap, + x: &syn::Expr, + ) -> RouteVal { + match self.eval(site, env, x) { + Some(Val::Route(rv)) => RouteVal::any(rv.handler), + _ => RouteVal::any(HandlerRef::Unknown), + } + } + + /// `App::route(path, route)` — 라우트 가드를 리소스 수준으로 옮긴다(app.rs:227-233). + fn route_resource( + &mut self, + site: &FnSite, + env: &BTreeMap, + p: &syn::Expr, + r: &syn::Expr, + ) -> Svc { + let route = match self.eval(site, env, r) { + Some(Val::Route(rv)) => rv, + _ => RouteVal::any(HandlerRef::Unknown), + }; + Svc::Resource(ResourceVal { + paths: vec![path_arg(self.ctx, &site.module, &site.file, p)], + loc: Loc { + file: site.file.clone(), + span: p.span(), + }, + guard: route.guard.clone(), + routes: vec![RouteVal::any(route.handler)], + }) + } + + /// `.service(x)`의 서비스들 — 매크로 핸들러, 리소스·스코프 식, 튜플. + fn services_of( + &mut self, + site: &FnSite, + env: &BTreeMap, + x: &syn::Expr, + ) -> Vec { + let loc = Loc { + file: site.file.clone(), + span: x.span(), + }; + match x { + syn::Expr::Tuple(t) => { + return t + .elems + .iter() + .flat_map(|e| self.services_of(site, env, e)) + .collect() + } + syn::Expr::Paren(p) => return self.services_of(site, env, &p.expr), + syn::Expr::Path(p) + if p.path + .get_ident() + .is_none_or(|i| !env.contains_key(&i.to_string())) => + { + let segs = path_segments(&p.path); + if let Some(id) = self.ctx.resolve_vertex(&site.module, &segs) { + if self.macros.contains_key(&id) { + self.registered.insert(id.clone()); + return vec![Svc::Macro(id)]; + } + } + return vec![Svc::Unknown { + loc, + what: "a service the analyzer could not resolve".to_string(), + }]; + } + _ => {} + } + match self.eval(site, env, x) { + Some(Val::Resource(r)) => vec![Svc::Resource(r)], + Some(Val::Scope(s)) => vec![Svc::Scope(s)], + Some(Val::Files(p)) => vec![Svc::Files { prefix: p }], + _ => vec![Svc::Unknown { + loc, + what: "a service the analyzer could not evaluate".to_string(), + }], + } + } + + /// `.configure(f)` — `fn f(cfg: &mut ServiceConfig)` 또는 클로저 본문의 등록. + fn configure(&mut self, site: &FnSite, f: &syn::Expr) -> (Vec, Vec) { + let loc = Loc { + file: site.file.clone(), + span: f.span(), + }; + let unknown = |what: &str| { + ( + vec![Svc::Unknown { + loc: loc.clone(), + what: what.to_string(), + }], + Vec::new(), + ) + }; + match f { + syn::Expr::Closure(c) => { + let Some(param) = c.inputs.first().and_then(pat_ident) else { + return unknown("a configure closure without a named parameter"); + }; + let mut acc = Config::default(); + self.config_expr(site, ¶m, &c.body, &mut acc); + (acc.services, acc.defaults) + } + syn::Expr::Path(p) => { + let segs = path_segments(&p.path); + match self.ctx.resolve_vertex(&site.module, &segs) { + Some(id) if self.fns.contains_key(&id) => { + let mut acc = Config::default(); + self.config_fn(&id, &mut acc); + (acc.services, acc.defaults) + } + _ => unknown("a configure function the analyzer could not resolve"), + } + } + _ => unknown("a configure argument the analyzer could not evaluate"), + } + } + + /// `fn f(cfg: &mut ServiceConfig)`의 본문을 평가해 등록을 모은다. + fn config_fn(&mut self, id: &str, acc: &mut Config) { + if self.stack.iter().any(|s| s == id) { + return; + } + let Some(site) = self.fns.get(id) else { return }; + let Some(param) = site.item.sig.inputs.iter().find_map(|a| match a { + syn::FnArg::Typed(t) => pat_ident(&t.pat), + syn::FnArg::Receiver(_) => None, + }) else { + return; + }; + self.stack.push(id.to_string()); + for stmt in &site.item.block.stmts { + if let syn::Stmt::Expr(e, _) = stmt { + self.config_expr(site, ¶m, e, acc); + } + } + self.stack.pop(); + } + + /// ServiceConfig 식 하나 — `cfg.service(..)` 체인, 블록, 다른 설정 함수 호출. + fn config_expr(&mut self, site: &FnSite, param: &str, e: &syn::Expr, acc: &mut Config) { + let env = BTreeMap::new(); + match e { + syn::Expr::Block(b) => { + for stmt in &b.block.stmts { + if let syn::Stmt::Expr(x, _) = stmt { + self.config_expr(site, param, x, acc); + } + } + } + syn::Expr::MethodCall(m) => { + // 체인을 뿌리부터 적용한다: cfg.service(a).service(b) + let mut calls = Vec::new(); + let mut cur: &syn::Expr = e; + while let syn::Expr::MethodCall(mc) = cur { + calls.push(mc); + cur = &mc.receiver; + } + let rooted = matches!(cur, syn::Expr::Path(p) if p.path.is_ident(param)); + if !rooted { + let _ = m; + return; + } + for mc in calls.into_iter().rev() { + let args: Vec<&syn::Expr> = mc.args.iter().collect(); + match (mc.method.to_string().as_str(), args.as_slice()) { + ("service", [x]) => { + let svcs = self.services_of(site, &env, x); + acc.services.extend(svcs); + } + ("route", [p, r]) => { + let svc = self.route_resource(site, &env, p, r); + acc.services.push(svc); + } + ("configure", [f]) => { + let (svcs, defaults) = self.configure(site, f); + acc.services.extend(svcs); + acc.defaults.extend(defaults); + } + ("default_service", [_]) => acc.defaults.push(Loc { + file: site.file.clone(), + span: mc.span(), + }), + _ => {} + } + } + } + syn::Expr::Call(c) => { + // 설정 함수에 cfg를 넘기는 호출 — `items::config(cfg)` + let passes = c.args.iter().any(|a| { + let a = match a { + syn::Expr::Reference(r) => &*r.expr, + other => other, + }; + matches!(a, syn::Expr::Path(p) if p.path.is_ident(param)) + }); + if let (true, syn::Expr::Path(p)) = (passes, &*c.func) { + let segs = path_segments(&p.path); + if let Some(id) = self.ctx.resolve_vertex(&site.module, &segs) { + if self.fns.contains_key(&id) { + self.config_fn(&id, acc); + return; + } + } + acc.services.push(Svc::Unknown { + loc: Loc { + file: site.file.clone(), + span: c.span(), + }, + what: "a configuration call the analyzer could not resolve".to_string(), + }); + } + } + syn::Expr::If(_) + | syn::Expr::Match(_) + | syn::Expr::ForLoop(_) + | syn::Expr::While(_) + if mentions_ident(e, param) => + { + acc.services.push(Svc::Unknown { + loc: Loc { + file: site.file.clone(), + span: e.span(), + }, + what: "a conditional or repeated registration".to_string(), + }); + } + _ => {} + } + } +} + +/// ServiceConfig 누적. +#[derive(Default)] +struct Config { + services: Vec, + defaults: Vec, +} + +/// 식이 식별자 `name`을 쓰는가. +fn mentions_ident(e: &syn::Expr, name: &str) -> bool { + struct Finder<'n>(&'n str, bool); + impl Visit<'_> for Finder<'_> { + fn visit_path(&mut self, p: &syn::Path) { + if p.is_ident(self.0) { + self.1 = true; + } + syn::visit::visit_path(self, p); + } + } + let mut f = Finder(name, false); + f.visit_expr(e); + f.1 +} + +/// 메서드 체인의 뿌리가 식별자면 그 이름(`app.route(..)`의 `app`). +fn chain_root_ident(e: &syn::Expr) -> Option { + let mut cur = e; + while let syn::Expr::MethodCall(m) = cur { + cur = &m.receiver; + } + match cur { + syn::Expr::Path(p) => p.path.get_ident().map(|i| i.to_string()), + _ => None, + } +} + +/// 메서드 체인의 뿌리가 `tail` 호출(예: `App::new()`)인가. +fn chain_root_is(imports: &Imports, e: &syn::Expr, tail: &[&str]) -> bool { + let mut cur = e; + while let syn::Expr::MethodCall(m) = cur { + cur = &m.receiver; + } + matches!(cur, syn::Expr::Call(c) if matches!(&*c.func, syn::Expr::Path(p) + if is_api(imports, &path_segments(&p.path), "actix_web", tail))) +} + +/// `Method::GET`·`http::Method::POST` 같은 method 식. +fn method_guard(e: &syn::Expr) -> Guard { + match e { + syn::Expr::Path(p) => { + let last = p + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(); + if HTTP_METHODS.contains(&last.as_str()) || last == "CONNECT" { + Guard { + methods: Some([last].into()), + narrowed: false, + } + } else { + Guard { + methods: None, + narrowed: true, + } + } + } + _ => Guard { + methods: None, + narrowed: true, + }, + } +} + +/// 가드 식 — `guard::Get()`·`guard::Method(..)`·`guard::Any(a).or(b)`·`guard::All(a).and(b)`. +/// method가 아닌 가드(`Header`·`Host`·`fn_guard` 등)는 narrowed다. +fn guard_of(e: &syn::Expr) -> Guard { + let narrowed = Guard { + methods: None, + narrowed: true, + }; + match e { + syn::Expr::Call(c) => { + let syn::Expr::Path(p) = &*c.func else { + return narrowed; + }; + let last = p + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(); + let upper = last.to_ascii_uppercase(); + match last.as_str() { + "Method" if c.args.len() == 1 => method_guard(&c.args[0]), + "Any" | "All" if c.args.len() == 1 => guard_of(&c.args[0]), + _ if c.args.is_empty() + && (HTTP_METHODS.contains(&upper.as_str()) || upper == "CONNECT") => + { + Guard { + methods: Some([upper].into()), + narrowed: false, + } + } + _ => narrowed, + } + } + syn::Expr::MethodCall(m) + if m.args.len() == 1 && (m.method == "or" || m.method == "and") => + { + let a = guard_of(&m.receiver); + let b = guard_of(&m.args[0]); + if m.method == "and" { + return a.and(&b); + } + match (a.methods, b.methods) { + (Some(x), Some(y)) if !a.narrowed && !b.narrowed => Guard { + methods: Some(x.union(&y).cloned().collect()), + narrowed: false, + }, + _ => narrowed, + } + } + syn::Expr::Paren(p) => guard_of(&p.expr), + _ => narrowed, + } +} + +/// `NormalizePath::trim()`·`NormalizePath::new(TrailingSlash::X)`·`::default()`. +fn normalize_of(e: &syn::Expr) -> Option { + let syn::Expr::Call(c) = e else { + return None; + }; + let syn::Expr::Path(p) = &*c.func else { + return None; + }; + let segs = path_segments(&p.path); + let pos = segs.iter().position(|s| s == "NormalizePath")?; + Some(match segs.get(pos + 1).map(String::as_str) { + Some("trim") | Some("default") => Normalize::Trim, + Some("new") => match c.args.first() { + Some(syn::Expr::Path(a)) => match a + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .as_deref() + { + Some("Trim") => Normalize::Trim, + Some("Always") => Normalize::Always, + Some("MergeOnly") => Normalize::MergeOnly, + _ => Normalize::Unknown, + }, + _ => Normalize::Unknown, + }, + _ => Normalize::Unknown, + }) +} + +/// 라우트 매크로 속성 하나를 읽는다(codegen route.rs:148-313). +fn parse_macro( + site: &FnSite, + attr: &syn::Attribute, + verb: Option, +) -> Result { + let args = attr + .parse_args_with(Punctuated::::parse_terminated) + .map_err(|_| "has arguments the analyzer cannot read".to_string())?; + let mut iter = args.iter(); + let Some((path, span)) = iter.next().and_then(str_lit) else { + return Err("has no literal path".to_string()); + }; + let loc = Loc { + file: site.file.clone(), + span, + }; + let mut methods: BTreeSet = verb.into_iter().collect(); + let mut narrowed = false; + for arg in iter { + let syn::Expr::Assign(a) = arg else { continue }; + let key = match &*a.left { + syn::Expr::Path(p) => p.path.get_ident().map(|i| i.to_string()), + _ => None, + }; + match key.as_deref() { + Some("method") => { + let Some((m, _)) = str_lit(&a.right) else { + return Err("has a non-literal method".to_string()); + }; + if !HTTP_METHODS.contains(&m.as_str()) { + return Err(format!( + "uses method {m}, which the http domain has no verb for" + )); + } + methods.insert(m); + } + Some("guard") => narrowed = true, + _ => {} + } + } + if methods.iter().any(|m| m == "CONNECT") { + return Err("is a CONNECT route, which the http domain has no verb for".to_string()); + } + Ok(MacroReg { + path: PathArg::Lit(path, loc.clone()), + loc, + methods: Some(methods), + narrowed, + }) +} + +/// 스코프 접두사 결합 — `ResourceDef::root_prefix`는 비어 있지 않은 경로에 앞 `/`를 +/// 붙이고(resource.rs:327-329), 안쪽 리소스는 `ensure_leading_slash`(dev.rs:30-47)다. +fn actix_join(prefix: &str, path: &str) -> String { + if path.is_empty() || path.starts_with('/') { + format!("{prefix}{path}") + } else { + format!("{prefix}/{path}") + } +} + +/// 서비스를 선언·한계로 바꾼다. +struct Emit<'a, 'o> { + ctx: &'a Ctx<'a>, + out: &'o mut Output, + /// 매크로 핸들러 ID → 등록들. + macros: &'a BTreeMap>, + /// base 선언의 접미사 스코프 원소(없으면 스코프 불가). + bases: Vec>, +} + +impl Emit<'_, '_> { + fn at(&self, loc: &Loc) -> String { + match self.ctx.locate(loc) { + Some(l) => format!("{}:{}", l.path, l.line), + None => "".to_string(), + } + } + + /// 서비스 목록을 등록 순서대로 편다. `order`가 있으면 리소스마다 index 하나. + fn services( + &mut self, + svcs: &[Svc], + prefix: &JPath, + guard: &Guard, + normalize: Normalize, + mut order: Option<(&str, &mut u64)>, + ) { + for svc in svcs { + match svc { + Svc::Resource(r) => { + let idx = order.as_mut().map(|(g, i)| { + let n = **i; + **i += 1; + (g.to_string(), n) + }); + self.resource_decls( + &r.paths, + &r.loc, + &guard.and(&r.guard), + &r.routes, + prefix, + normalize, + idx, + None, + ); + } + Svc::Macro(id) => { + let regs = self.macro_regs_of(id); + for reg in regs { + let idx = order.as_mut().map(|(g, i)| { + let n = **i; + **i += 1; + (g.to_string(), n) + }); + self.resource_decls( + std::slice::from_ref(®.path), + ®.loc, + &guard.and(&Guard { + methods: reg.methods.clone(), + narrowed: reg.narrowed, + }), + &[RouteVal::any(HandlerRef::Unknown)], + prefix, + normalize, + idx, + Some(Handler::Usr(id.clone())), + ); + } + } + Svc::Scope(s) => { + let inner = self.scope_prefix(prefix, &s.prefix); + for d in &s.defaults { + let text = format!( + "a scope default service at {} receives requests under the scope that no route matches", + self.at(d) + ); + self.prefix_gap("route-coverage:", text, &inner); + } + let n = s.normalize.unwrap_or(normalize); + let reborrow = order.as_mut().map(|(g, i)| (*g, &mut **i)); + self.services(&s.services, &inner, &guard.and(&s.guard), n, reborrow); + } + Svc::Files { prefix: p } => { + let inner = self.scope_prefix(prefix, p); + let text = format!( + "an actix-files service at {} serves files under its prefix", + self.at(p.loc()) + ); + self.prefix_gap_methods( + "framework-provided-routes:", + text, + &inner, + &["GET", "HEAD"], + ); + } + Svc::Unknown { loc, what } => { + let text = format!("{what} at {} may register routes", self.at(loc)); + self.prefix_gap("route-coverage:", text, prefix); + } + } + } + } + + /// 매크로 핸들러의 등록 목록. + fn macro_regs_of(&self, id: &str) -> Vec { + self.macros.get(id).cloned().unwrap_or_default() + } + + /// 스코프 접두사를 잇는다. + fn scope_prefix(&self, prefix: &JPath, p: &PathArg) -> JPath { + let raw = JPath::of(p); + let raw = match raw { + JPath::Lit(s) if !s.is_empty() && !s.starts_with('/') => JPath::Lit(format!("/{s}")), + other => other, + }; + prefix.join(&raw, |a, b| format!("{a}{b}"), p.loc()) + } + + /// 접두사 아래 전부를 덮는 한계. + fn prefix_gap(&mut self, prefix: &'static str, text: String, path: &JPath) { + self.prefix_gap_methods(prefix, text, path, &[]); + } + + fn prefix_gap_methods( + &mut self, + prefix: &'static str, + text: String, + path: &JPath, + methods: &[&str], + ) { + if let JPath::Lit(raw) = path { + let raw = if raw.is_empty() { "/" } else { raw.as_str() }; + if let Ok(p) = parse_actix(raw) { + if let Some(t) = prefix_template(&p.segs) { + if t != "/" || !methods.is_empty() { + self.out.scoped_gap( + prefix, + text, + ScopeSpec { + prefixes: vec![t], + methods: methods.iter().map(|m| m.to_string()).collect(), + ..Default::default() + }, + ); + return; + } + } + } + } + self.out.gap(prefix, text); + } + + /// 리소스 하나(경로 여러 개 × 라우트들)를 선언으로 낸다. + #[allow(clippy::too_many_arguments)] + fn resource_decls( + &mut self, + paths: &[PathArg], + loc: &Loc, + guard: &Guard, + routes: &[RouteVal], + prefix: &JPath, + normalize: Normalize, + order: Option<(String, u64)>, + fixed_handler: Option, + ) { + for p in paths { + let joined = prefix.join(&JPath::of(p), actix_join, p.loc()); + let (path, anchor) = match &joined { + JPath::Lit(raw) if raw.is_empty() => continue, + JPath::Lit(raw) | JPath::Base(raw) => { + let base = matches!(joined, JPath::Base(_)); + let raw = if raw.is_empty() { + "/".to_string() + } else { + raw.clone() + }; + match parse_actix(&raw) { + Ok(parsed) => ( + DeclPath::Template { + segs: parsed.segs, + empty_tail: parsed + .empty_tail + .then(|| empty_tail_trailing(normalize)) + .flatten(), + }, + if base { Anchor::Base } else { Anchor::Root }, + ), + Err(why) => { + self.out.gap( + "route-coverage:", + format!( + "route pattern {raw:?} at {} {why}; the declaration is dynamic", + self.at(loc) + ), + ); + (DeclPath::Dynamic(raw.clone()), Anchor::Root) + } + } + } + JPath::Dyn(dl) => { + self.out.gap( + "route-coverage:", + format!( + "a route path at {} is not a literal; the declaration is dynamic", + self.at(dl) + ), + ); + ( + DeclPath::Dynamic(self.ctx.source_text(&dl.file, dl.span)), + Anchor::Root, + ) + } + }; + if anchor == Anchor::Base { + self.bases.push(match &path { + DeclPath::Template { segs, .. } => base_suffix(segs), + DeclPath::Dynamic(_) => None, + }); + } + let trailing = trailing_of(&path, normalize); + for r in routes { + let g = guard.and(&r.guard); + let methods: Vec = match &g.methods { + None => vec!["ANY".to_string()], + Some(ms) => { + if ms.contains("CONNECT") { + self.out.gap( + "route-coverage:", + format!( + "a CONNECT route at {} has no http-domain method", + self.at(loc) + ), + ); + } + ms.iter().filter(|m| *m != "CONNECT").cloned().collect() + } + }; + if methods.is_empty() { + continue; + } + let handler = match &fixed_handler { + Some(h) => h.clone(), + None => r.handler.resolve(self.ctx), + }; + self.out.decls.push(Decl { + methods, + path: path.clone(), + anchor, + trailing, + handler, + loc: loc.clone(), + order: if anchor == Anchor::Root { + order.clone() + } else { + None + }, + narrowed: g.narrowed, + }); + } + } + } + + /// base 선언 공백 — `unresolved-route-prefix:`와 순서 미상. + fn finish_bases(&mut self) { + if self.bases.is_empty() { + return; + } + let n = self.bases.len(); + let scope = self.bases.iter().all(Option::is_some).then(|| { + let mut suffixes: Vec = self.bases.iter().flatten().cloned().collect(); + suffixes.sort(); + suffixes.dedup(); + ScopeSpec { + suffixes, + ..Default::default() + } + }); + let texts = [ + ( + "unresolved-route-prefix:", + format!("{n} route declaration(s) are not registered on an App the analyzer could follow, or sit under a non-literal scope prefix; they are emitted with pathAnchor base"), + ), + ( + "route-dispatch-order-unknown:", + format!("{n} base-anchored route declaration(s) carry no registration order"), + ), + ]; + for (prefix, text) in texts { + match &scope { + Some(s) => self.out.scoped_gap(prefix, text, s.clone()), + None => self.out.gap(prefix, text), + } + } + } +} + +/// base 선언을 덮는 접미사 스코프 원소. +fn base_suffix(segs: &[Seg]) -> Option { + if segs.iter().any(|s| matches!(s, Seg::CatchAll)) { + return None; + } + let t = render(segs); + (t != "/").then_some(t) +} + +/// 빈 끝 변형(`/files/`)의 끝 슬래시 판정. Trim은 그 요청을 `/files`로 바꿔 꼬리 +/// 파라미터에 닿지 않게 하므로 변형이 없고, Always는 `/files`도 `/files/`로 바꿔 닿게 한다. +fn empty_tail_trailing(n: Normalize) -> Option { + match n { + Normalize::None | Normalize::MergeOnly => Some(Trailing::Strict), + Normalize::Trim => None, + Normalize::Always => Some(Trailing::Optional), + Normalize::Unknown => Some(Trailing::Unknown), + } +} + +/// 끝 슬래시 판정 — 정규화 미들웨어가 없으면 엄격(resource.rs 문서 196-208). +fn trailing_of(p: &DeclPath, n: Normalize) -> Trailing { + let DeclPath::Template { segs, .. } = p else { + return Trailing::Unknown; + }; + if matches!(segs.last(), Some(Seg::CatchAll)) { + return Trailing::Unknown; + } + let t = render(segs); + let ends_slash = t.len() > 1 && t.ends_with('/'); + match n { + Normalize::None | Normalize::MergeOnly => Trailing::Strict, + Normalize::Trim if t == "/" => Trailing::Strict, + Normalize::Trim if !ends_slash => Trailing::Optional, + Normalize::Always if ends_slash => Trailing::Optional, + Normalize::Trim | Normalize::Always => Trailing::Strict, + Normalize::Unknown => Trailing::Unknown, + } +} diff --git a/src/source/routes/axum.rs b/src/source/routes/axum.rs new file mode 100644 index 0000000..b194af8 --- /dev/null +++ b/src/source/routes/axum.rs @@ -0,0 +1,1378 @@ +//! axum 0.7·0.8 라우터 선언 추출. +//! +//! 규칙과 근거(axum·matchit 소스 줄 번호)는 docs/HTTP-ROUTES.md에 있다. 요약: +//! +//! - 경로 문법은 버전마다 다르다. 0.7(matchit 0.7)은 `:name`·`*name`, 0.8(matchit +//! 0.8)은 `{name}`·`{*name}`과 `{{`·`}}` 이스케이프다. 0.8은 `:`·`*`로 시작하는 +//! 세그먼트를 기동 시 panic으로 거부한다. +//! - matchit은 정적 > 파라미터 > catch-all 우선순위와 되돌아가기로 고른다 → +//! `dispatch: "specificity"`. 끝 슬래시는 엄격하다(`/a`와 `/a/`는 다른 경로). +//! - `nest`는 `path_for_nested_route` 규칙으로 문자열을 잇는다. +//! - 라우터 값은 한 함수 안의 `let`·재대입, 크레이트 함수 호출(반환식)을 따라 +//! 정적으로 평가한다. 조건·반복 등록, 외부 함수가 만든 라우터는 한계다. + +use super::common::{ + handler_ref, is_api, pat_ident, path_arg, prefix_template, unevaluated_constructors, Anchor, + Ctx, Decl, DeclPath, FnSite, Handler, HandlerRef, Imports, JPath, Loc, Output, PathArg, + ScopeSpec, Trailing, +}; +use super::template::{normalize_uri_path, render, template_problem, Seg}; +use crate::harvest::path_segments; +use std::collections::{BTreeMap, BTreeSet}; +use syn::spanned::Spanned; +use syn::visit::Visit; + +/// 경로 문법 버전이다. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Version { + /// matchit 0.7 문법(`:name`·`*name`) — axum 0.7.x(0.6.x도 같은 문법). + V07, + /// matchit 0.8 문법(`{name}`·`{*name}`) — axum 0.8.x. + V08, +} + +/// 메서드 라우터 생성자·체인 메서드 이름과 동사다. +const METHOD_FNS: &[(&str, &str)] = &[ + ("get", "GET"), + ("post", "POST"), + ("put", "PUT"), + ("delete", "DELETE"), + ("patch", "PATCH"), + ("head", "HEAD"), + ("options", "OPTIONS"), + ("trace", "TRACE"), + ("connect", "CONNECT"), +]; + +/// 한 크레이트의 axum 라우트를 추출한다. +pub(super) fn extract(ctx: &Ctx, krate: &str, version: Version, out: &mut Output) { + let fns = ctx.crate_fns(krate); + let mut ev = Eval { + ctx, + fns: &fns, + memo: BTreeMap::new(), + stack: Vec::new(), + consumed: BTreeSet::new(), + imports: BTreeMap::new(), + }; + // 1) 서빙되는 라우터(루트) — `axum::serve(_, X)`·`X.into_make_service()`. + let mut roots: Vec = Vec::new(); + for site in fns.values() { + for expr in served_exprs(&ev, site) { + let mut env = Env::default(); + ev.fill_env(site, &site.item.block, &mut env); + if let Some(Val::Router(r)) = ev.eval(site, &env, &expr) { + roots.push(r); + } + } + } + // 2) 라우터를 만드는 함수 중 루트에서 닿지 않은 것 — 어디에 붙는지 모른다. + let builders: Vec = fns + .values() + .filter(|s| builds_router(&ev, s)) + .map(|s| s.id.clone()) + .collect(); + let mut flat = Vec::new(); + for r in &roots { + ev.flatten(r, &mut flat); + } + let rooted = flat.len(); + // 먼저 전부 평가해 다른 라우터에 쓰인 함수를 표시한 뒤 남은 것만 낸다 — 한 번에 + // 하면 이름 순서에 따라 안쪽 라우터가 따로 한 번, 바깥 라우터 안에서 또 한 번 나온다. + for id in &builders { + let _ = ev.eval_fn(id); + } + for id in &builders { + if ev.consumed.contains(id) { + continue; + } + if let Some(Val::Router(r)) = ev.eval_fn(id) { + if !r.entries.is_empty() { + let mut own = Vec::new(); + ev.flatten(&r, &mut own); + for f in &mut own { + f.path = rebase(std::mem::replace(&mut f.path, JPath::Lit(String::new()))); + } + flat.extend(own); + } + } + } + for loc in unevaluated_constructors(ctx, krate, "axum", &["Router", "new"]) { + let at = ctx + .locate(&loc) + .map(|l| format!("{}:{}", l.path, l.line)) + .unwrap_or_default(); + out.gap( + "route-coverage:", + format!("a Router built inside a method at {at} is not evaluated; its routes are not extracted"), + ); + } + let normalize = normalize_effect(&fns); + let mut emit = Emit { + ctx, + version, + normalize, + out, + bases: Vec::new(), + variants: Vec::new(), + }; + for (i, f) in flat.iter().enumerate() { + emit.flat(f, i >= rooted); + } + emit.drop_shadowed_variants(); + emit.finish_bases(); +} + +/// 경로 정규화 미들웨어의 효과다(Router 바깥을 감쌀 때만 라우팅 전에 동작한다). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Normalize { + None, + Trim, + Append, + Unknown, +} + +/// 라우터 값 — 등록 순서대로의 항목. +#[derive(Clone, Debug, Default)] +struct RouterVal { + entries: Vec, +} + +/// 라우터 항목 하나. +#[derive(Clone, Debug)] +enum Entry { + Route { + path: PathArg, + method: MethodVal, + }, + Nest { + prefix: PathArg, + inner: RouterVal, + }, + NestService { + prefix: PathArg, + }, + RouteService { + path: PathArg, + }, + Merge(RouterVal), + Fallback { + loc: Loc, + }, + /// 평가하지 못한 라우터 조각(외부 함수·조건 등) — 그 자리의 접두사 아래 한계다. + Unknown { + loc: Loc, + what: String, + }, +} + +/// 메서드 라우터 값. +#[derive(Clone, Debug, Default)] +struct MethodVal { + routes: Vec, + /// 평가하지 못한 메서드 라우터 식이 섞였다. + unknown: bool, +} + +/// 메서드 라우터 항목 — `methods`가 None이면 모든 method(ANY)다. +#[derive(Clone, Debug)] +struct MRoute { + methods: Option>, + handler: HandlerRef, +} + +/// 평가 값. +#[derive(Clone, Debug)] +enum Val { + Router(RouterVal), + Method(MethodVal), +} + +/// 함수 본문의 지역 값(이름 → 마지막으로 대입된 값). 흐름 비민감 근사다. +#[derive(Default)] +struct Env { + vars: BTreeMap, +} + +/// 평가기 — 함수 반환 라우터를 기억하고 순환 호출을 막는다. +struct Eval<'a> { + ctx: &'a Ctx<'a>, + fns: &'a BTreeMap, + memo: BTreeMap>, + stack: Vec, + /// 다른 라우터·서빙 식에 쓰인 라우터 함수. + consumed: BTreeSet, + imports: BTreeMap, +} + +impl Eval<'_> { + /// 모듈의 `use` 표(캐시). + fn imports(&mut self, module: &str) -> &Imports { + if !self.imports.contains_key(module) { + let groups = self.ctx.parts.module_items(module); + self.imports + .insert(module.to_string(), Imports::of(&groups)); + } + &self.imports[module] + } + + /// 경로가 axum API(`tail`로 끝나는 axum 경로)인가. + fn is_axum(&mut self, module: &str, segs: &[String], tail: &[&str]) -> bool { + is_api(self.imports(module), segs, "axum", tail) + } + + /// 크레이트 함수의 반환 값을 평가한다(기억·순환 방지). + fn eval_fn(&mut self, id: &str) -> Option { + if let Some(v) = self.memo.get(id) { + return v.clone(); + } + if self.stack.iter().any(|s| s == id) { + return None; + } + let site = self.fns.get(id)?; + self.stack.push(id.to_string()); + let mut env = Env::default(); + let result = self.eval_block(site, &mut env, &site.item.block); + self.stack.pop(); + self.memo.insert(id.to_string(), result.clone()); + result + } + + /// 블록을 순서대로 평가해 지역을 채우고 꼬리식(또는 `return`) 값을 돌려준다. + fn eval_block(&mut self, site: &FnSite, env: &mut Env, block: &syn::Block) -> Option { + let mut result = None; + for stmt in &block.stmts { + match stmt { + syn::Stmt::Local(l) => self.bind_local(site, env, l), + syn::Stmt::Expr(syn::Expr::Assign(a), _) => self.assign(site, env, a), + syn::Stmt::Expr(syn::Expr::Return(r), _) => { + if let Some(e) = &r.expr { + return self.eval(site, env, e); + } + } + syn::Stmt::Expr(e, None) => result = self.eval(site, env, e), + _ => {} + } + } + result + } + + /// 함수 본문 전체의 지역을 채운다(서빙 식 평가용 — 꼬리식은 버린다). + fn fill_env(&mut self, site: &FnSite, block: &syn::Block, env: &mut Env) { + for stmt in &block.stmts { + match stmt { + syn::Stmt::Local(l) => self.bind_local(site, env, l), + syn::Stmt::Expr(syn::Expr::Assign(a), _) => self.assign(site, env, a), + _ => {} + } + } + } + + /// `let x = <라우터 식>;`을 지역에 묶는다. + fn bind_local(&mut self, site: &FnSite, env: &mut Env, l: &syn::Local) { + let (Some(name), Some(init)) = (pat_ident(&l.pat), &l.init) else { + return; + }; + match self.eval(site, env, &init.expr) { + Some(v) => { + env.vars.insert(name, v); + } + None => { + env.vars.remove(&name); + } + } + } + + /// `x = <라우터 식>;` 재대입. + fn assign(&mut self, site: &FnSite, env: &mut Env, a: &syn::ExprAssign) { + let syn::Expr::Path(p) = &*a.left else { return }; + let Some(name) = p.path.get_ident().map(|i| i.to_string()) else { + return; + }; + match self.eval(site, env, &a.right) { + Some(v) => { + env.vars.insert(name, v); + } + None => { + env.vars.remove(&name); + } + } + } + + /// 식을 라우터·메서드 라우터 값으로 평가한다. 모르면 None. + fn eval(&mut self, site: &FnSite, env: &Env, e: &syn::Expr) -> Option { + match e { + syn::Expr::Paren(p) => self.eval(site, env, &p.expr), + syn::Expr::Group(g) => self.eval(site, env, &g.expr), + syn::Expr::Reference(r) => self.eval(site, env, &r.expr), + syn::Expr::Await(a) => self.eval(site, env, &a.base), + syn::Expr::Try(t) => self.eval(site, env, &t.expr), + syn::Expr::Block(b) => { + let mut inner = Env { + vars: env.vars.clone(), + }; + self.eval_block(site, &mut inner, &b.block) + } + syn::Expr::Path(p) => { + let name = p.path.get_ident()?.to_string(); + env.vars.get(&name).cloned() + } + syn::Expr::Call(c) => self.eval_call(site, env, c), + syn::Expr::MethodCall(m) => self.eval_method(site, env, m), + _ => None, + } + } + + /// 함수 호출 — `Router::new()`, 메서드 라우터 생성자, 크레이트 함수. + fn eval_call(&mut self, site: &FnSite, env: &Env, c: &syn::ExprCall) -> Option { + let syn::Expr::Path(p) = &*c.func else { + return None; + }; + let segs = path_segments(&p.path); + let module = site.module.clone(); + // 크레이트 함수가 먼저다 — 사용자가 `get`이라는 함수를 정의했을 수 있다. + if let Some(id) = self.ctx.resolve_vertex(&module, &segs) { + if self.fns.contains_key(&id) { + // 라우터를 돌려주지 않는 크레이트 함수면 아래 axum API 판정으로 넘긴다. + if let Some(v) = self.eval_fn(&id) { + self.consumed.insert(id); + return Some(v); + } + } + } + if self.is_axum(&module, &segs, &["Router", "new"]) { + return Some(Val::Router(RouterVal::default())); + } + if self.is_axum(&module, &segs, &["MethodRouter", "new"]) { + return Some(Val::Method(MethodVal::default())); + } + let name = segs.last()?.clone(); + if !self.is_axum(&module, &segs, &[name.as_str()]) { + return None; + } + let mut mv = MethodVal::default(); + self.apply_method( + site, + env, + &mut mv, + &name, + &c.args.iter().collect::>(), + ) + .then_some(Val::Method(mv)) + } + + /// 체인 메서드 — 수신자 값에 등록을 더한다. + fn eval_method(&mut self, site: &FnSite, env: &Env, m: &syn::ExprMethodCall) -> Option { + let recv = self.eval(site, env, &m.receiver)?; + let name = m.method.to_string(); + let args: Vec<&syn::Expr> = m.args.iter().collect(); + match recv { + Val::Router(mut r) => { + self.apply_router(site, env, &mut r, &name, &args); + Some(Val::Router(r)) + } + Val::Method(mut mv) => { + if !self.apply_method(site, env, &mut mv, &name, &args) { + // 레이어·상태 등 등록을 바꾸지 않는 메서드는 투명하다. + } + Some(Val::Method(mv)) + } + } + } + + /// Router 체인 메서드 하나를 적용한다. 모르는 메서드(`layer`·`with_state` 등)는 투명. + fn apply_router( + &mut self, + site: &FnSite, + env: &Env, + r: &mut RouterVal, + name: &str, + args: &[&syn::Expr], + ) { + let file = site.file.clone(); + let loc_of = |e: &syn::Expr| Loc { + file: file.clone(), + span: e.span(), + }; + match (name, args) { + ("route", [p, mr]) => { + let path = path_arg(self.ctx, &site.module, &site.file, p); + let method = match self.eval(site, env, mr) { + Some(Val::Method(mv)) => mv, + _ => MethodVal { + routes: Vec::new(), + unknown: true, + }, + }; + r.entries.push(Entry::Route { path, method }); + } + ("nest", [p, inner]) => { + let prefix = path_arg(self.ctx, &site.module, &site.file, p); + match self.eval(site, env, inner) { + Some(Val::Router(ir)) => r.entries.push(Entry::Nest { prefix, inner: ir }), + _ => r.entries.push(Entry::Nest { + prefix, + inner: RouterVal { + entries: vec![Entry::Unknown { + loc: loc_of(inner), + what: "a nested router the analyzer could not evaluate".to_string(), + }], + }, + }), + } + } + ("merge", [inner]) => match self.eval(site, env, inner) { + Some(Val::Router(ir)) => r.entries.push(Entry::Merge(ir)), + _ => r.entries.push(Entry::Unknown { + loc: loc_of(inner), + what: "a merged router the analyzer could not evaluate".to_string(), + }), + }, + ("nest_service", [p, _]) => r.entries.push(Entry::NestService { + prefix: path_arg(self.ctx, &site.module, &site.file, p), + }), + ("route_service", [p, _]) => r.entries.push(Entry::RouteService { + path: path_arg(self.ctx, &site.module, &site.file, p), + }), + ("fallback" | "fallback_service", [h]) => { + r.entries.push(Entry::Fallback { loc: loc_of(h) }) + } + _ => {} + } + } + + /// 메서드 라우터 생성자·체인 메서드를 적용한다. 등록을 바꿨으면 true. + fn apply_method( + &mut self, + site: &FnSite, + env: &Env, + mv: &mut MethodVal, + name: &str, + args: &[&syn::Expr], + ) -> bool { + let verb = |n: &str| { + METHOD_FNS + .iter() + .find(|(f, _)| *f == n) + .map(|(_, v)| v.to_string()) + }; + let service = name.strip_suffix("_service"); + match (name, args) { + ("any", [h]) | ("fallback", [h]) => mv.routes.push(MRoute { + methods: None, + handler: handler_ref(&site.module, &site.file, h), + }), + ("on", [filter, h]) => mv.routes.push(MRoute { + methods: method_filter(filter), + handler: handler_ref(&site.module, &site.file, h), + }), + ("on_service", [filter, _]) => mv.routes.push(MRoute { + methods: method_filter(filter), + handler: HandlerRef::Unknown, + }), + ("any_service" | "fallback_service", [_]) => mv.routes.push(MRoute { + methods: None, + handler: HandlerRef::Unknown, + }), + ("merge", [other]) => match self.eval(site, env, other) { + Some(Val::Method(o)) => { + mv.routes.extend(o.routes); + mv.unknown |= o.unknown; + } + _ => mv.unknown = true, + }, + (n, [h]) if verb(n).is_some() => mv.routes.push(MRoute { + methods: verb(n).map(|v| vec![v]), + handler: handler_ref(&site.module, &site.file, h), + }), + (_, [_]) if service.and_then(verb).is_some() => mv.routes.push(MRoute { + methods: service.and_then(verb).map(|v| vec![v]), + handler: HandlerRef::Unknown, + }), + _ => return false, + } + true + } + + /// 라우터 값을 (결합된 경로, 항목) 목록으로 편다. nest 접두사는 axum과 같은 + /// 규칙(`path_for_nested_route`)으로 안쪽 경로에 잇는다. + fn flatten(&mut self, r: &RouterVal, out: &mut Vec) { + for entry in &r.entries { + match entry { + Entry::Route { path, method } => out.push(Flat { + path: JPath::of(path), + kind: FlatKind::Route(method.clone(), path.loc().clone()), + }), + Entry::RouteService { path } => out.push(Flat { + path: JPath::of(path), + kind: FlatKind::RouteService(path.loc().clone()), + }), + Entry::NestService { prefix } => out.push(Flat { + path: JPath::of(prefix), + kind: FlatKind::NestService(prefix.loc().clone()), + }), + Entry::Fallback { loc } => out.push(Flat { + path: JPath::Lit("/".to_string()), + kind: FlatKind::Fallback(loc.clone(), false), + }), + Entry::Unknown { loc, what } => out.push(Flat { + path: JPath::Lit("/".to_string()), + kind: FlatKind::Unknown(loc.clone(), what.clone()), + }), + Entry::Merge(inner) => self.flatten(inner, out), + Entry::Nest { prefix, inner } => { + let mut own = Vec::new(); + self.flatten(inner, &mut own); + let p = JPath::of(prefix); + for mut f in own { + f.kind = f.kind.nested(); + let prefix_item = f.kind.is_prefix_item(); + let glue = |a: &str, b: &str| { + if prefix_item { + nest_prefix_join(a, b) + } else { + path_for_nested_route(a, b) + } + }; + f.path = p.join(&f.path, glue, f.kind.loc()); + out.push(f); + } + } + } + } + } +} + +/// 식이 이름 `name`을 경로 세그먼트나 메서드 이름으로 담는가 — 레이어 판별용. +fn mentions(e: &syn::Expr, name: &str) -> bool { + struct Finder<'n>(&'n str, bool); + impl Visit<'_> for Finder<'_> { + fn visit_path(&mut self, p: &syn::Path) { + if p.segments.iter().any(|s| s.ident == self.0) { + self.1 = true; + } + syn::visit::visit_path(self, p); + } + fn visit_expr_method_call(&mut self, m: &syn::ExprMethodCall) { + if m.method == self.0 { + self.1 = true; + } + syn::visit::visit_expr_method_call(self, m); + } + } + let mut f = Finder(name, false); + f.visit_expr(e); + f.1 +} + +/// `MethodFilter::GET.or(MethodFilter::POST)` 같은 필터의 동사들. 모르면 None(ANY 근사). +fn method_filter(e: &syn::Expr) -> Option> { + match e { + syn::Expr::Path(p) => { + let last = p.path.segments.last()?.ident.to_string(); + METHOD_FNS + .iter() + .any(|(_, v)| *v == last) + .then(|| vec![last]) + } + syn::Expr::MethodCall(m) if m.method == "or" && m.args.len() == 1 => { + let mut a = method_filter(&m.receiver)?; + a.extend(method_filter(&m.args[0])?); + a.sort(); + a.dedup(); + Some(a) + } + syn::Expr::Paren(p) => method_filter(&p.expr), + _ => None, + } +} + +/// axum `path_for_nested_route`(0.7.9 path_router.rs:496, 0.8.9 path_router.rs:535). +fn path_for_nested_route(prefix: &str, path: &str) -> String { + if prefix.ends_with('/') { + format!("{prefix}{}", path.trim_start_matches('/')) + } else if path == "/" { + prefix.to_string() + } else { + format!("{prefix}{path}") + } +} + +/// 접두사 자리 항목(중첩 fallback·nest_service)의 접두사 결합 — 안쪽 접두사가 +/// `/`면 바깥 접두사 그대로다. +fn nest_prefix_join(prefix: &str, inner: &str) -> String { + if inner == "/" { + prefix.to_string() + } else { + path_for_nested_route(prefix, inner) + } +} + +/// 루트에서 닿지 않은 라우터의 경로는 알 수 없는 접두사 뒤다. +fn rebase(p: JPath) -> JPath { + match p { + JPath::Lit(s) => JPath::Base(s), + other => other, + } +} + +/// 편 항목 하나. +#[derive(Clone, Debug)] +struct Flat { + path: JPath, + kind: FlatKind, +} + +/// 편 항목의 종류 — bool은 nest 아래(접두사 자리)인지다. +#[derive(Clone, Debug)] +enum FlatKind { + Route(MethodVal, Loc), + RouteService(Loc), + NestService(Loc), + Fallback(Loc, bool), + Unknown(Loc, String), +} + +impl FlatKind { + /// nest 안으로 들어간 항목 표시. + fn nested(self) -> FlatKind { + match self { + FlatKind::Fallback(l, _) => FlatKind::Fallback(l, true), + other => other, + } + } + + /// 항목의 소스 위치. + fn loc(&self) -> &Loc { + match self { + FlatKind::Route(_, l) + | FlatKind::RouteService(l) + | FlatKind::NestService(l) + | FlatKind::Fallback(l, _) + | FlatKind::Unknown(l, _) => l, + } + } + + /// 경로가 아니라 접두사를 뜻하는 항목인가. + fn is_prefix_item(&self) -> bool { + matches!( + self, + FlatKind::NestService(_) | FlatKind::Fallback(..) | FlatKind::Unknown(..) + ) + } +} + +/// 함수가 `Router::new()`를 부르는가(라우터를 만드는 함수 후보). +fn builds_router(ev: &Eval, site: &FnSite) -> bool { + struct Finder<'a, 'b> { + imports: &'b Imports, + found: bool, + _p: std::marker::PhantomData<&'a ()>, + } + impl Visit<'_> for Finder<'_, '_> { + fn visit_expr_call(&mut self, c: &syn::ExprCall) { + if let syn::Expr::Path(p) = &*c.func { + if is_api( + self.imports, + &path_segments(&p.path), + "axum", + &["Router", "new"], + ) { + self.found = true; + } + } + syn::visit::visit_expr_call(self, c); + } + } + let groups = ev.ctx.parts.module_items(&site.module); + let imports = Imports::of(&groups); + let mut f = Finder { + imports: &imports, + found: false, + _p: std::marker::PhantomData, + }; + f.visit_block(&site.item.block); + f.found +} + +/// 함수 본문에서 서빙되는 라우터 식 — `serve(_, X)`, `X.into_make_service()`. +fn served_exprs(ev: &Eval, site: &FnSite) -> Vec { + struct Finder<'b> { + imports: &'b Imports, + out: Vec, + } + impl Visit<'_> for Finder<'_> { + fn visit_expr_call(&mut self, c: &syn::ExprCall) { + if let syn::Expr::Path(p) = &*c.func { + if is_api(self.imports, &path_segments(&p.path), "axum", &["serve"]) + && c.args.len() == 2 + { + self.out.push(strip_make_service(&c.args[1])); + // 인자의 into_make_service를 다시 세지 않는다. + return; + } + } + syn::visit::visit_expr_call(self, c); + } + fn visit_expr_method_call(&mut self, m: &syn::ExprMethodCall) { + if m.method == "into_make_service" || m.method == "into_make_service_with_connect_info" + { + self.out.push((*m.receiver).clone()); + // 수신자 안의 serve 호출은 없다 — 다시 내려가지 않는다. + return; + } + syn::visit::visit_expr_method_call(self, m); + } + } + let groups = ev.ctx.parts.module_items(&site.module); + let imports = Imports::of(&groups); + let mut f = Finder { + imports: &imports, + out: Vec::new(), + }; + f.visit_block(&site.item.block); + f.out +} + +/// `app.into_make_service()` 인자는 수신자 라우터다. +fn strip_make_service(e: &syn::Expr) -> syn::Expr { + if let syn::Expr::MethodCall(m) = e { + if m.method == "into_make_service" || m.method == "into_make_service_with_connect_info" { + return (*m.receiver).clone(); + } + } + e.clone() +} + +/// 크레이트의 `NormalizePathLayer` 효과 — 라우터 **바깥**을 감싼 증거만 센다. +/// +/// `Router::layer`·`MethodRouter::layer` 미들웨어는 라우팅 뒤에 돈다(axum layer.md:57-62) +/// 그래서 효과를 인정하는 모양은 레이어가 서비스를 감싸는 경우뿐이다: +/// `NormalizePathLayer::trim_trailing_slash().layer(svc)`, `ServiceBuilder::new()… +/// .layer(NormalizePathLayer::…).service(svc)`, `NormalizePath::trim_trailing_slash(svc)`. +/// 변수에 담아 넘기는 등 모양을 못 읽으면 효과가 없다고 본다 — 틀리면 선언이 strict로 +/// 남아 소비자는 끝 슬래시 불일치 경고를 내고, 반대로 틀리면 거짓 match가 된다. +fn normalize_effect(fns: &BTreeMap) -> Normalize { + #[derive(Default)] + struct Finder { + trim: usize, + append: usize, + } + impl Finder { + fn mode(&mut self, e: &syn::Expr) { + if mentions(e, "trim_trailing_slash") { + self.trim += 1; + } + if mentions(e, "append_trailing_slash") { + self.append += 1; + } + } + } + impl Visit<'_> for Finder { + fn visit_expr_method_call(&mut self, m: &syn::ExprMethodCall) { + // 체인의 뿌리와 체인 메서드들 — 안쪽 인자에 섞인 레이어는 세지 않는다. + let mut calls = Vec::new(); + let mut root: &syn::Expr = &m.receiver; + while let syn::Expr::MethodCall(mc) = root { + calls.push(mc); + root = &mc.receiver; + } + let root_is = |name: &str| { + matches!(root, syn::Expr::Call(c) if matches!(&*c.func, + syn::Expr::Path(p) if p.path.segments.iter().any(|s| s.ident == name))) + }; + if m.method == "layer" && root_is("NormalizePathLayer") { + // NormalizePathLayer::trim_trailing_slash().layer(svc) + self.mode(&m.receiver); + } else if m.method == "service" && root_is("ServiceBuilder") { + // ServiceBuilder::new().layer(NormalizePathLayer::..).service(svc) + for mc in calls.iter().filter(|mc| mc.method == "layer") { + if let Some(arg) = mc + .args + .first() + .filter(|a| mentions(a, "NormalizePathLayer")) + { + self.mode(arg); + } + } + } + syn::visit::visit_expr_method_call(self, m); + } + fn visit_expr_call(&mut self, c: &syn::ExprCall) { + if let syn::Expr::Path(p) = &*c.func { + let segs = path_segments(&p.path); + if segs.iter().any(|s| s == "NormalizePath") && c.args.len() == 1 { + self.mode(&c.func); + } + } + syn::visit::visit_expr_call(self, c); + } + } + let mut f = Finder::default(); + for site in fns.values() { + f.visit_block(&site.item.block); + } + match (f.trim > 0, f.append > 0) { + (false, false) => Normalize::None, + (true, false) => Normalize::Trim, + (false, true) => Normalize::Append, + (true, true) => Normalize::Unknown, + } +} + +/// 편 항목을 선언·한계로 바꾼다. +struct Emit<'a, 'o> { + ctx: &'a Ctx<'a>, + version: Version, + normalize: Normalize, + out: &'o mut Output, + /// base 선언의 (접미사 스코프 원소, 루트에서 닿지 않은 라우터인가) — 한계용. + bases: Vec<(Option, bool)>, + /// 빈 값 변형 선언의 `out.decls` 위치. + variants: Vec, +} + +impl Emit<'_, '_> { + fn flat(&mut self, f: &Flat, unrooted: bool) { + match &f.kind { + FlatKind::Route(mv, loc) => self.route(&f.path, mv, loc, unrooted), + FlatKind::RouteService(loc) => { + let mv = MethodVal { + routes: vec![MRoute { + methods: None, + handler: HandlerRef::Unknown, + }], + unknown: false, + }; + self.route(&f.path, &mv, loc, unrooted); + } + FlatKind::NestService(loc) => self.prefix_gap( + "framework-provided-routes:", + format!( + "a nested tower service at {} may serve any path under its prefix", + self.at(loc) + ), + &f.path, + ), + FlatKind::Fallback(loc, nested) => { + let text = format!( + "a router fallback at {} receives requests that no route matches", + self.at(loc) + ); + if *nested { + self.prefix_gap("route-coverage:", text, &f.path); + } else { + self.out.gap("route-coverage:", text); + } + } + FlatKind::Unknown(loc, what) => { + let text = format!("{} at {} may register routes", what, self.at(loc)); + self.prefix_gap("route-coverage:", text, &f.path); + } + } + } + + /// `파일:줄` 표기. + fn at(&self, loc: &Loc) -> String { + match self.ctx.locate(loc) { + Some(l) => format!("{}:{}", l.path, l.line), + None => "".to_string(), + } + } + + /// 접두사 아래 전부를 덮는 한계 — 접두사가 리터럴 root일 때만 스코프를 단다. + fn prefix_gap(&mut self, prefix: &'static str, text: String, path: &JPath) { + if let JPath::Lit(raw) = path { + if let Ok(segs) = parse(self.version, raw) { + if let Some(t) = prefix_template(&segs) { + if t != "/" { + self.out.scoped_gap( + prefix, + text, + ScopeSpec { + prefixes: vec![t], + ..Default::default() + }, + ); + return; + } + } + } + } + self.out.gap(prefix, text); + } + + /// 경로 등록 하나를 method별 선언으로 낸다. + fn route(&mut self, path: &JPath, mv: &MethodVal, loc: &Loc, unrooted: bool) { + if mv.unknown { + self.out.gap( + "route-coverage:", + format!( + "a method router at {} could not be evaluated; its methods are unknown", + self.at(loc) + ), + ); + } + let (paths, anchor) = match path { + JPath::Lit(raw) | JPath::Base(raw) => { + let anchor = if matches!(path, JPath::Base(_)) { + Anchor::Base + } else { + Anchor::Root + }; + let raw = if raw.is_empty() { "/" } else { raw.as_str() }; + match parse(self.version, raw) { + Ok(segs) => match self.empty_variants(segs, loc) { + Some(vs) => (vs, anchor), + None => (vec![DeclPath::Dynamic(raw.to_string())], Anchor::Root), + }, + Err(e) => match self.parse_failure(raw, loc, e) { + Some(p) => (vec![p], Anchor::Root), + None => return, + }, + } + } + JPath::Dyn(dl) => { + self.out.gap( + "route-coverage:", + format!( + "a route path at {} is not a literal; the declaration is dynamic", + self.at(dl) + ), + ); + // 루트에서 닿지 않은 라우터의 dynamic 선언도 붙는 곳을 모른다. + ( + vec![DeclPath::Dynamic(self.ctx.source_text(&dl.file, dl.span))], + if unrooted { Anchor::Base } else { Anchor::Root }, + ) + } + }; + if anchor == Anchor::Base { + for p in &paths { + let suffix = match p { + DeclPath::Template { segs, .. } => base_suffix(segs), + DeclPath::Dynamic(_) => None, + }; + self.bases.push((suffix, unrooted)); + } + } + for r in &mv.routes { + let methods = match &r.methods { + None => vec!["ANY".to_string()], + Some(ms) => { + let mut keep = Vec::new(); + for m in ms { + if m == "CONNECT" { + self.out.gap( + "route-coverage:", + format!( + "a CONNECT route at {} has no http-domain method", + self.at(loc) + ), + ); + } else { + keep.push(m.clone()); + } + } + keep + } + }; + if methods.is_empty() { + continue; + } + for (i, p) in paths.iter().enumerate() { + // 빈 값 변형(첫 원소 뒤)은 나중에 같은 템플릿의 진짜 선언과 겹치는지 본다. + if i > 0 { + self.variants.push(self.out.decls.len()); + } + self.out.decls.push(Decl { + methods: methods.clone(), + path: p.clone(), + anchor, + trailing: self.trailing(p), + handler: self.handler(&r.handler), + loc: loc.clone(), + order: None, + narrowed: false, + }); + } + } + } + + /// 빈 값 변형 — matchit은 마지막이 아닌 세그먼트의 파라미터(세그먼트 전체·앞 + /// 글자 붙은 파라미터)가 빈 값과도 맞는다(오라클 실측: `/items//tags/x`, + /// `/v/status`). 계약의 `{}`는 비어 있지 않으므로 빈 값 자리를 리터럴로 채운 + /// 변형을 함께 낸다. 16개를 넘으면 None(dynamic과 펼침 상한 한계). + fn empty_variants(&mut self, segs: Vec, loc: &Loc) -> Option> { + let last = segs.len().saturating_sub(1); + let slots: Vec = segs + .iter() + .enumerate() + .filter(|(i, s)| *i < last && matches!(s, Seg::Param { .. })) + .map(|(i, _)| i) + .collect(); + if slots.len() > 4 { + self.out.gap( + "route-template-expansion-capped:", + format!( + "a route at {} has {} parameters that also match an empty value; more than 16 variants, so the declaration is dynamic", + self.at(loc), + slots.len() + ), + ); + return None; + } + let mut out = Vec::new(); + for mask in 0u32..(1 << slots.len()) { + let mut v = segs.clone(); + for (bit, &i) in slots.iter().enumerate() { + if mask & (1 << bit) != 0 { + if let Seg::Param { prefix, suffix, .. } = &v[i] { + v[i] = Seg::Lit(format!("{prefix}{suffix}")); + } + } + } + out.push(DeclPath::Template { + segs: v, + empty_tail: None, + }); + } + Some(out) + } + + /// 템플릿으로 못 바꾼 경로의 처리 — 기동 시 panic하는 경로는 선언이 아니고 + /// 서버 전체가 뜨지 않으므로 스코프 없는 한계, 원문 경로로만 닿는 경로는 그 + /// 정규 템플릿 스코프의 한계, 세그먼트를 넘는 catch-all은 dynamic 선언이다. + fn parse_failure(&mut self, raw: &str, loc: &Loc, e: PathError) -> Option { + let at = self.at(loc); + match e { + PathError::Panic(why) => { + self.out.gap( + "route-coverage:", + format!("route path {raw:?} at {at} {why}"), + ); + None + } + PathError::Encoded(canonical) => { + let text = format!( + "route path {raw:?} at {at} has characters that clients percent-encode; axum matches the raw request path, so encoded requests do not reach it" + ); + match canonical { + Some(t) => self.out.scoped_gap( + "route-coverage:", + text, + ScopeSpec { + templates: vec![t], + ..Default::default() + }, + ), + None => self.out.gap("route-coverage:", text), + } + None + } + PathError::NoTemplate(why) => { + self.out.gap( + "route-coverage:", + format!("route path {raw:?} at {at} {why}; the declaration is dynamic"), + ); + Some(DeclPath::Dynamic(raw.to_string())) + } + } + } + + /// 같은 앵커·템플릿의 진짜 선언이 있는 빈 값 변형을 뺀다. matchit은 정적 경로를 + /// 파라미터보다 먼저 고르고 경로가 맞으면 method가 달라도 거기서 끝나므로(405), + /// 그 경로의 요청은 method와 무관하게 변형의 핸들러에 닿지 않는다(오라클 실측). + fn drop_shadowed_variants(&mut self) { + let key = |d: &Decl| match &d.path { + DeclPath::Template { segs, .. } => Some((d.anchor, render(segs))), + DeclPath::Dynamic(_) => None, + }; + let variants: BTreeSet = self.variants.iter().copied().collect(); + let real: BTreeSet<(Anchor, String)> = self + .out + .decls + .iter() + .enumerate() + .filter(|(i, _)| !variants.contains(i)) + .filter_map(|(_, d)| key(d)) + .collect(); + let mut i = 0; + self.out.decls.retain(|d| { + let shadowed = variants.contains(&i) && key(d).is_some_and(|k| real.contains(&k)); + i += 1; + !shadowed + }); + self.variants.clear(); + } + + /// 핸들러 식을 정점 ID로 해석한다. + fn handler(&self, h: &HandlerRef) -> Handler { + h.resolve(self.ctx) + } + + /// 끝 슬래시 판정 — axum은 엄격하다. 바깥 경로 정규화 레이어가 있으면 바뀐다. + fn trailing(&self, p: &DeclPath) -> Trailing { + let DeclPath::Template { segs, .. } = p else { + return Trailing::Unknown; + }; + if matches!(segs.last(), Some(Seg::CatchAll)) { + return Trailing::Unknown; + } + let rendered = render(segs); + let ends_slash = rendered.len() > 1 && rendered.ends_with('/'); + match self.normalize { + Normalize::None => Trailing::Strict, + Normalize::Trim if rendered == "/" => Trailing::Strict, + Normalize::Trim if !ends_slash => Trailing::Optional, + Normalize::Append if ends_slash => Trailing::Optional, + Normalize::Trim | Normalize::Append => Trailing::Strict, + Normalize::Unknown => Trailing::Unknown, + } + } + + /// base 선언의 공백을 사유별로 한 번씩 낸다 — 스코프는 모든 base 템플릿을 + /// 접미사로 쓸 수 있을 때만 단다. + fn finish_bases(&mut self) { + for unrooted in [true, false] { + let group: Vec<&Option> = self + .bases + .iter() + .filter(|(_, u)| *u == unrooted) + .map(|(s, _)| s) + .collect(); + if group.is_empty() { + continue; + } + let n = group.len(); + let text = if unrooted { + format!("{n} route declaration(s) come from routers that are not reachable from a served router (axum::serve or into_make_service); they are emitted with pathAnchor base") + } else { + format!("{n} route declaration(s) sit under a nest prefix that is not a literal; they are emitted with pathAnchor base") + }; + if group.iter().all(|s| s.is_some()) { + let mut suffixes: Vec = group.iter().filter_map(|s| (*s).clone()).collect(); + suffixes.sort(); + suffixes.dedup(); + self.out.scoped_gap( + "unresolved-route-prefix:", + text, + ScopeSpec { + suffixes, + ..Default::default() + }, + ); + } else { + self.out.gap("unresolved-route-prefix:", text); + } + } + } +} + +/// base 선언을 덮는 접미사 스코프 원소 — `{**}`·루트는 접미사로 쓸 수 없다. +fn base_suffix(segs: &[Seg]) -> Option { + if segs.iter().any(|s| matches!(s, Seg::CatchAll)) { + return None; + } + let t = render(segs); + (t != "/").then_some(t) +} + +/// 템플릿으로 못 바꾼 axum 경로의 사유. +#[derive(Debug)] +pub(super) enum PathError { + /// axum이 기동 시 panic으로 거부한다. + Panic(String), + /// 리터럴에 클라이언트가 인코딩하는 글자가 있다 — 정규 템플릿(있으면). + Encoded(Option), + /// 라우트는 있지만 정규 템플릿으로 쓸 수 없다. + NoTemplate(String), +} + +/// 버전별 axum 경로 문법을 세그먼트로 바꾼다. +/// +/// matchit은 요청의 원문(퍼센트 인코딩된) 경로를 바이트로 비교한다. 그래서 리터럴이 +/// 정규형(`normalize_uri_path`)과 다르면(중괄호·공백·ASCII 밖 글자·인코딩된 +/// unreserved) 정규 템플릿으로 보낸 요청은 닿지 않는다 — `Encoded`로 돌려준다. +pub(super) fn parse(version: Version, raw: &str) -> Result, PathError> { + if !raw.starts_with('/') { + return Err(PathError::Panic( + "does not start with `/` (axum panics at startup)".to_string(), + )); + } + let body = &raw[1..]; + let parts: Vec<&str> = body.split('/').collect(); + let mut segs = Vec::with_capacity(parts.len()); + for (i, part) in parts.iter().enumerate() { + let last = i == parts.len() - 1; + let seg = match version { + Version::V07 => segment_v07(part, last), + Version::V08 => segment_v08(part, last), + }; + segs.push(seg?); + } + let raw_literal = |t: &str| normalize_uri_path(t) == t; + let encoded = segs.iter().any(|s| match s { + Seg::Lit(t) => !raw_literal(t), + Seg::Param { prefix, suffix, .. } => !raw_literal(prefix) || !raw_literal(suffix), + Seg::CatchAll => false, + }); + if encoded { + let t = render(&segs); + return Err(PathError::Encoded( + template_problem(&t).is_none().then_some(t), + )); + } + Ok(segs) +} + +/// matchit 0.7 세그먼트: `:`·`*`는 세그먼트 어디서든 와일드카드를 시작해 다음 +/// `/`까지 이어진다(tree.rs:651-670). `:name` 뒤 글자는 이름에 흡수되므로 파라미터는 +/// 항상 세그먼트 끝까지다. `*name`은 `/` 바로 뒤, 경로 끝에만 온다. +fn segment_v07(part: &str, last: bool) -> Result { + let Some(at) = part.find([':', '*']) else { + return Ok(Seg::Lit(part.to_string())); + }; + let name = &part[at + 1..]; + if name.is_empty() { + return Err(PathError::Panic( + "has an unnamed parameter (axum panics at startup)".to_string(), + )); + } + if name.contains([':', '*']) { + return Err(PathError::Panic( + "has two parameters in one segment (axum panics at startup)".to_string(), + )); + } + if part.as_bytes()[at] == b'*' { + if at != 0 || !last { + return Err(PathError::Panic( + "has a catch-all that is not a whole last segment (axum panics at startup)" + .to_string(), + )); + } + return Ok(Seg::CatchAll); + } + Ok(Seg::Param { + prefix: part[..at].to_string(), + suffix: String::new(), + constraint: None, + }) +} + +/// matchit 0.8 세그먼트: `{name}`은 앞에 정적 글자를 둘 수 있지만 뒤에는 못 둔다 +/// (tree.rs:783-788 InvalidParamSegment). `{*name}`은 경로 끝에만 온다(372-374). +/// `{{`·`}}`는 리터럴 중괄호다(escape.rs). `:`·`*`로 시작하는 세그먼트는 axum이 +/// 기동 시 panic한다(path_router.rs:53-73 validate_v07_paths). +fn segment_v08(part: &str, last: bool) -> Result { + if part.starts_with(':') || part.starts_with('*') { + return Err(PathError::Panic( + "starts a segment with `:` or `*`, which axum 0.8 rejects at startup".to_string(), + )); + } + let mut lit = String::new(); + let mut chars = part.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + match c { + '{' if chars.peek().map(|(_, n)| *n) == Some('{') => { + chars.next(); + lit.push('{'); + } + '}' if chars.peek().map(|(_, n)| *n) == Some('}') => { + chars.next(); + lit.push('}'); + } + '{' => { + let rest = &part[i + 1..]; + let Some(close) = rest.find('}') else { + return Err(PathError::Panic( + "has an unclosed `{` (axum panics at startup)".to_string(), + )); + }; + let name = &rest[..close]; + let after = &rest[close + 1..]; + if name.is_empty() || name == "*" { + return Err(PathError::Panic( + "has an unnamed parameter (axum panics at startup)".to_string(), + )); + } + if !after.is_empty() { + return Err(PathError::Panic( + "has text after a parameter in one segment (axum panics at startup)" + .to_string(), + )); + } + if name.starts_with('*') { + if !last { + return Err(PathError::Panic( + "has a catch-all before the end (axum panics at startup)".to_string(), + )); + } + if !lit.is_empty() { + return Err(PathError::NoTemplate( + "has a catch-all after static text in one segment; the capture can span segments, so it has no canonical template" + .to_string(), + )); + } + return Ok(Seg::CatchAll); + } + return Ok(Seg::Param { + prefix: lit, + suffix: String::new(), + constraint: None, + }); + } + '}' => { + return Err(PathError::Panic( + "has an unmatched `}` (axum panics at startup)".to_string(), + )) + } + other => lit.push(other), + } + } + Ok(Seg::Lit(lit)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(v: Version, raw: &str) -> String { + match parse(v, raw) { + Ok(segs) => render(&segs), + Err(e) => format!("ERR {e:?}"), + } + } + + #[test] + fn v07_syntax() { + assert_eq!(t(Version::V07, "/items/:id"), "/items/{}"); + assert_eq!(t(Version::V07, "/user_:id"), "/user_{}"); + assert_eq!(t(Version::V07, "/f/:id.json"), "/f/{}"); + assert_eq!(t(Version::V07, "/files/*rest"), "/files/{**}"); + assert!(t(Version::V07, "/{id}").starts_with("ERR Encoded")); + assert!(t(Version::V07, "/a/:x:y").starts_with("ERR")); + assert!(t(Version::V07, "/a*x").starts_with("ERR")); + } + + #[test] + fn v08_syntax() { + assert_eq!(t(Version::V08, "/items/{id}"), "/items/{}"); + assert_eq!(t(Version::V08, "/v{version}/x"), "/v{}/x"); + assert_eq!(t(Version::V08, "/files/{*rest}"), "/files/{**}"); + assert!(t(Version::V08, "/{{x}}").starts_with("ERR Encoded")); + assert_eq!(t(Version::V08, "/a:b"), "/a:b"); + assert!(t(Version::V08, "/:id").starts_with("ERR")); + assert!(t(Version::V08, "/{id}.json").starts_with("ERR")); + assert!(t(Version::V08, "/pre{*rest}").starts_with("ERR")); + } + + #[test] + fn nest_join_matches_axum() { + assert_eq!(path_for_nested_route("/api", "/"), "/api"); + assert_eq!(path_for_nested_route("/api", "/x"), "/api/x"); + assert_eq!(path_for_nested_route("/api/", "/x"), "/api/x"); + assert_eq!(path_for_nested_route("/api/", "/"), "/api/"); + assert_eq!(path_for_nested_route("/", "/x"), "/x"); + } +} diff --git a/src/source/routes/common.rs b/src/source/routes/common.rs new file mode 100644 index 0000000..417ff67 --- /dev/null +++ b/src/source/routes/common.rs @@ -0,0 +1,709 @@ +//! 프레임워크 추출기가 공유하는 모델과 syn 도우미. +//! +//! 추출기(axum·actix)는 선언을 [`Decl`]로, 서버 측 공백을 [`Gap`]으로 낸다. 문서 +//! 조립(템플릿 렌더링·usr·위치·스코프 검증)은 상위 모듈이 한 곳에서 한다 — 두 +//! 프레임워크가 계약 규칙을 따로 구현하면 갈라진다. + +use super::template::{render, Seg}; +use crate::source::schema::BridgeLocation; +use crate::source::Parts; +use proc_macro2::Span; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use syn::spanned::Spanned; + +/// 소스 위치 — 파일과 syn 스팬. 줄·열은 문서를 조립할 때 계산한다. +#[derive(Clone, Debug)] +pub(super) struct Loc { + pub file: PathBuf, + pub span: Span, +} + +/// 선언의 핸들러다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Handler { + /// 그래프 정점 ID로 해석된 함수·메서드. + Usr(String), + /// 클로저 핸들러 — usr는 감싸는 정점으로 근사하고 한계로 센다. + Closure(Loc), + /// 서비스·팩토리 호출 등 정점으로 해석하지 못한 핸들러. + Unknown, +} + +impl PartialEq for Loc { + fn eq(&self, other: &Self) -> bool { + self.file == other.file && self.span.byte_range() == other.span.byte_range() + } +} +impl Eq for Loc {} + +/// 끝 슬래시 판정이다. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Trailing { + /// 끝 슬래시가 다르면 이 핸들러에 닿지 않는다. + Strict, + /// 끝 슬래시가 있든 없든 닿는다(경로 정규화 미들웨어). + Optional, + /// 알 수 없음 — `trailingSlash`를 싣지 않는다. + Unknown, +} + +/// 템플릿이 서버 루트부터 확정됐는지(`root`) 알 수 없는 접두사 뒤인지(`base`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Anchor { + Root, + Base, +} + +/// 해석한 경로 — 세그먼트 또는 템플릿으로 확정하지 못한 원문. +#[derive(Clone, Debug)] +pub(super) enum DeclPath { + Template { + segs: Vec, + /// 끝 catch-all이 빈 나머지도 받으면 마지막 세그먼트를 비운 변형을 그 끝 + /// 슬래시 판정으로 함께 낸다. None이면 변형이 없다(받지 않거나, 경로 + /// 정규화가 그 요청을 다른 경로로 바꿔 닿지 않는다). + empty_tail: Option, + }, + Dynamic(String), +} + +/// 추출기가 낸 선언 하나 — method마다 사실 하나가 된다. +#[derive(Clone, Debug)] +pub(super) struct Decl { + /// 대문자 동사 또는 `ANY`. + pub methods: Vec, + pub path: DeclPath, + pub anchor: Anchor, + pub trailing: Trailing, + pub handler: Handler, + pub loc: Loc, + pub order: Option<(String, u64)>, + pub narrowed: bool, +} + +/// 한계 스코프 — 그 한계가 가릴 수 있는 요청의 상한이다. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct ScopeSpec { + pub templates: Vec, + pub prefixes: Vec, + pub suffixes: Vec, + pub methods: Vec, +} + +/// 서버 측 공백 하나 — 접두사는 계약의 닫힌 목록에서 고른다. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct Gap { + pub prefix: &'static str, + pub text: String, + pub scope: Option, +} + +/// 추출 결과. +#[derive(Default)] +pub(super) struct Output { + pub decls: Vec, + pub gaps: Vec, +} + +impl Output { + /// 스코프 없는 공백을 더한다. + pub fn gap(&mut self, prefix: &'static str, text: String) { + self.gaps.push(Gap { + prefix, + text, + scope: None, + }); + } + + /// 스코프 있는 공백을 더한다. + pub fn scoped_gap(&mut self, prefix: &'static str, text: String, scope: ScopeSpec) { + self.gaps.push(Gap { + prefix, + text, + scope: Some(scope), + }); + } +} + +/// 크레이트의 모듈 수준 함수 하나. +pub(super) struct FnSite { + pub id: String, + pub module: String, + pub file: PathBuf, + pub item: &'static syn::ItemFn, +} + +/// 추출기 공용 문맥 — 수확 산출물, 정점 집합, 파일 원문 캐시. +pub(super) struct Ctx<'a> { + pub parts: &'a Parts, + pub ids: BTreeSet<&'a str>, + /// 문서 경로의 기준(정규화된 워크스페이스 루트). + pub root: PathBuf, + sources: RefCell>>, + owners: BTreeMap, String)>>, + consts: BTreeMap, +} + +impl<'a> Ctx<'a> { + /// 수확 산출물로 문맥을 만든다. 정점 범위는 그래프에 실제로 있는 ID만 담는다. + pub fn new(parts: &'a Parts, root: PathBuf) -> Ctx<'a> { + let ids = parts.doc.vertex_ids(); + let mut owners: BTreeMap, String)>> = BTreeMap::new(); + for sp in &parts.spans { + if ids.contains(sp.id.as_str()) && !sp.range.is_empty() { + owners + .entry(sp.file.clone()) + .or_default() + .push((sp.range.clone(), sp.id.clone())); + } + } + let mut ctx = Ctx { + parts, + ids, + root, + sources: RefCell::new(BTreeMap::new()), + owners, + consts: BTreeMap::new(), + }; + ctx.consts = collect_str_consts(parts); + ctx + } + + /// 크레이트(루트 모듈 이름)의 테스트가 아닌 모듈 경로들이다. + pub fn crate_modules(&self, krate: &str) -> Vec { + let prefix = format!("{krate}::"); + self.parts + .tree + .modules + .keys() + .filter(|m| (*m == krate || m.starts_with(&prefix)) && !self.module_is_test(m)) + .cloned() + .collect() + } + + /// `#[cfg(test)]` 모듈(또는 그 자손)인가 — 테스트 소스는 선언에서 뺀다. + fn module_is_test(&self, module: &str) -> bool { + let mut cur = Some(module.to_string()); + while let Some(m) = cur { + let cfg = self + .parts + .tree + .modules + .get(&m) + .and_then(|x| x.cfg.as_deref()); + if cfg.is_some_and(cfg_mentions_test) { + return true; + } + cur = crate::modtree::parent_of(&m); + } + false + } + + /// 크레이트의 테스트가 아닌 모듈 수준 함수 — ID는 수확과 같은 `모듈::이름`이다. + pub fn crate_fns(&self, krate: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + for module in self.crate_modules(krate) { + for (file, items) in self.parts.module_items(&module) { + for item in items { + let syn::Item::Fn(f) = item else { continue }; + if is_test_item(&f.attrs) { + continue; + } + let id = format!("{module}::{}", f.sig.ident); + out.insert( + id.clone(), + FnSite { + id, + module: module.clone(), + file: file.clone(), + item: f, + }, + ); + } + } + } + out + } + + /// 모듈 기준으로 경로를 해석해 그래프 정점 ID를 돌려준다. 연관 함수 + /// (`Type::f`, 트레이트 impl의 `Type::::f`)도 정점 집합에서 찾는다. + pub fn resolve_vertex(&self, module: &str, segs: &[String]) -> Option { + let dep = crate::modtree::DepCrates::new(); + if let Some(id) = self.parts.tree.resolve(module, segs, &dep) { + if self.ids.contains(id.as_str()) { + return Some(id); + } + } + let (name, head) = segs.split_last()?; + if head.is_empty() { + return None; + } + let ty = self.parts.tree.resolve(module, head, &dep)?; + let inherent = format!("{ty}::{name}"); + if self.ids.contains(inherent.as_str()) { + return Some(inherent); + } + let prefix = format!("{ty}::<"); + let suffix = format!(">::{name}"); + let mut hits = self + .ids + .iter() + .filter(|id| id.starts_with(&prefix) && id.ends_with(&suffix)); + match (hits.next(), hits.next()) { + (Some(one), None) => Some(one.to_string()), + _ => None, + } + } + + /// 경로가 가리키는 `&str` 상수의 리터럴 값이다. + pub fn const_str(&self, module: &str, segs: &[String]) -> Option { + let dep = crate::modtree::DepCrates::new(); + let id = self.parts.tree.resolve(module, segs, &dep)?; + self.consts.get(&id).cloned() + } + + /// 바이트 오프셋을 감싸는 가장 안쪽 정점 — 클로저 핸들러의 usr 근사다. + pub fn owner_of(&self, loc: &Loc) -> Option { + let byte = loc.span.byte_range().start; + self.owners + .get(&loc.file)? + .iter() + .filter(|(r, _)| r.start <= byte && byte < r.end) + .min_by(|(ra, ia), (rb, ib)| (ra.len(), ia).cmp(&(rb.len(), ib))) + .map(|(_, id)| id.clone()) + } + + /// 계약의 위치(루트 기준 경로, 1 기반 줄, UTF-16 열)다. + pub fn locate(&self, loc: &Loc) -> Option { + let start = loc.span.start(); + if start.line == 0 { + return None; + } + let file = loc.file.canonicalize().unwrap_or_else(|_| loc.file.clone()); + let rel = file.strip_prefix(&self.root).ok()?; + let mut cache = self.sources.borrow_mut(); + let text = cache + .entry(loc.file.clone()) + .or_insert_with(|| std::fs::read_to_string(&loc.file).ok()) + .as_ref()?; + let line_text = text.lines().nth(start.line - 1)?; + // proc-macro2의 열은 문자 수다 — UTF-16 코드 단위로 바꾼다. + let column = line_text + .chars() + .take(start.column) + .map(|c| c.len_utf16() as u32) + .sum::() + + 1; + Some(BridgeLocation { + path: rel.to_string_lossy().replace('\\', "/"), + line: start.line as u32, + column, + byte: loc.span.byte_range().start, + }) + } + + /// 식의 원문(최대 2,048자) — dynamic 사실의 channel이다. + pub fn source_text(&self, file: &Path, span: Span) -> String { + let mut cache = self.sources.borrow_mut(); + let text = cache + .entry(file.to_path_buf()) + .or_insert_with(|| std::fs::read_to_string(file).ok()); + let raw = text + .as_deref() + .and_then(|t| t.get(span.byte_range())) + .unwrap_or(""); + take_utf16(raw, super::template::MAX_TEMPLATE_LENGTH) + } +} + +/// cfg 토큰이 `test`를 조건으로 쓰는가(`test`, `all(test, ..)` 등). +fn cfg_mentions_test(cfg: &str) -> bool { + cfg.split(|c: char| !c.is_alphanumeric() && c != '_') + .any(|w| w == "test") +} + +/// 테스트 항목(`#[test]`·`#[tokio::test]`·`#[actix_web::test]`·`#[cfg(test)]`)인가. +pub(super) fn is_test_item(attrs: &[syn::Attribute]) -> bool { + if crate::harvest::is_test_entry(attrs) { + return true; + } + attrs.iter().any(|a| { + a.path().is_ident("cfg") + && matches!(&a.meta, syn::Meta::List(l) if cfg_mentions_test(&l.tokens.to_string())) + }) +} + +/// 워크스페이스의 `const X: &str = "..";` 값 — 라우트 경로 상수 해석용. +fn collect_str_consts(parts: &Parts) -> BTreeMap { + let mut out = BTreeMap::new(); + for module in parts.tree.modules.keys() { + for (_, items) in parts.module_items(module) { + for item in items { + if let syn::Item::Const(c) = item { + if let Some((value, _)) = str_lit(&c.expr) { + out.insert(format!("{module}::{}", c.ident), value); + } + } + } + } + } + out +} + +/// 문자열 리터럴 식이면 값과 스팬. +pub(super) fn str_lit(expr: &syn::Expr) -> Option<(String, Span)> { + match expr { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) => Some((s.value(), s.span())), + syn::Expr::Paren(p) => str_lit(&p.expr), + syn::Expr::Group(g) => str_lit(&g.expr), + _ => None, + } +} + +/// 경로 인자 — 리터럴(또는 `&str` 상수)이면 값, 아니면 원문 스팬. +#[derive(Clone, Debug)] +pub(super) enum PathArg { + Lit(String, Loc), + Dyn(Loc), +} + +impl PathArg { + /// 인자의 위치. + pub fn loc(&self) -> &Loc { + match self { + PathArg::Lit(_, l) | PathArg::Dyn(l) => l, + } + } +} + +/// 경로 식을 읽는다 — 리터럴, 크레이트의 `&str` 상수 경로, 그 밖은 dynamic. +pub(super) fn path_arg(ctx: &Ctx, module: &str, file: &Path, expr: &syn::Expr) -> PathArg { + if let Some((value, span)) = str_lit(expr) { + return PathArg::Lit( + value, + Loc { + file: file.to_path_buf(), + span, + }, + ); + } + let loc = Loc { + file: file.to_path_buf(), + span: expr.span(), + }; + let inner = match expr { + syn::Expr::Reference(r) => &*r.expr, + other => other, + }; + if let syn::Expr::Path(p) = inner { + let segs = crate::harvest::path_segments(&p.path); + if let Some(value) = ctx.const_str(module, &segs) { + return PathArg::Lit(value, loc); + } + } + PathArg::Dyn(loc) +} + +/// 이름 해석 전에 `use`로 들여온 이름을 원래 경로로 펼친다. +/// +/// 모듈 트리의 import 표는 워크스페이스 안만 해석하므로 외부 크레이트(axum· +/// actix_web) 경로는 모듈의 `use` 선언을 직접 읽는다. 글롭은 접두사로 남긴다. +#[derive(Default, Debug)] +pub(super) struct Imports { + names: BTreeMap>, + globs: Vec>, +} + +impl Imports { + /// 모듈 아이템(여러 파일일 수 있다)의 `use` 선언을 모은다. + pub fn of(groups: &[(PathBuf, &'static [syn::Item])]) -> Imports { + let mut out = Imports::default(); + for (_, items) in groups { + for item in *items { + if let syn::Item::Use(u) = item { + out.collect(&u.tree, &mut Vec::new()); + } + } + } + out + } + + fn collect(&mut self, tree: &syn::UseTree, prefix: &mut Vec) { + match tree { + syn::UseTree::Path(p) => { + prefix.push(p.ident.to_string()); + self.collect(&p.tree, prefix); + prefix.pop(); + } + syn::UseTree::Name(n) => { + let name = n.ident.to_string(); + let mut full = prefix.clone(); + if name != "self" { + full.push(name.clone()); + } + let local = if name == "self" { + prefix.last().cloned().unwrap_or_default() + } else { + name + }; + self.names.insert(local, full); + } + syn::UseTree::Rename(r) => { + let mut full = prefix.clone(); + if r.ident != "self" { + full.push(r.ident.to_string()); + } + self.names.insert(r.rename.to_string(), full); + } + syn::UseTree::Glob(_) => self.globs.push(prefix.clone()), + syn::UseTree::Group(g) => { + for t in &g.items { + self.collect(t, prefix); + } + } + } + } + + /// 경로의 첫 세그먼트를 `use` 원래 경로로 펼친다. `::a::b` 선행 콜론은 없다고 본다. + pub fn expand(&self, segs: &[String]) -> Vec { + let Some((first, rest)) = segs.split_first() else { + return Vec::new(); + }; + match self.names.get(first) { + Some(full) => full.iter().chain(rest.iter()).cloned().collect(), + None => segs.to_vec(), + } + } + + /// 단일 이름이 `krate`로 시작하는 글롭에서 왔을 수 있는가. + pub fn globbed_from(&self, krate: &str) -> bool { + self.globs + .iter() + .any(|g| g.first().is_some_and(|f| f == krate)) + } +} + +/// 크레이트 루트 모듈 이름 `krate`의 외부 API 경로인가 — 펼친 경로가 그 크레이트로 +/// 시작하고 `tail`로 끝나거나, 한 세그먼트 이름이 그 크레이트 글롭에서 왔다. +pub(super) fn is_api(imports: &Imports, segs: &[String], krate: &str, tail: &[&str]) -> bool { + let full = imports.expand(segs); + let ends = full.len() >= tail.len() + && full[full.len() - tail.len()..] + .iter() + .zip(tail) + .all(|(a, b)| a == b); + if !ends { + return false; + } + if full.first().is_some_and(|f| f == krate) { + return true; + } + // 글롭으로 들여온 이름(`use axum::routing::*;` 뒤 `get`)은 펼쳐지지 않는다. + full.len() == tail.len() && imports.globbed_from(krate) +} + +/// 템플릿을 스코프 원소로 쓸 수 있게 렌더링한다(접두사는 끝 `/`를 뗀다). +pub(super) fn prefix_template(segs: &[Seg]) -> Option { + if segs.iter().any(|s| matches!(s, Seg::CatchAll)) { + return None; + } + let t = render(segs); + if t == "/" { + return Some(t); + } + let trimmed = t.trim_end_matches('/'); + Some(if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() + }) +} + +/// `x`·`mut x`·`x: T` 패턴의 이름. +pub(super) fn pat_ident(p: &syn::Pat) -> Option { + match p { + syn::Pat::Ident(i) if i.subpat.is_none() => Some(i.ident.to_string()), + syn::Pat::Type(t) => pat_ident(&t.pat), + _ => None, + } +} + +/// 해석 전 핸들러 식. +#[derive(Clone, Debug)] +pub(super) enum HandlerRef { + Path { module: String, segs: Vec }, + Closure(Loc), + Unknown, +} + +impl HandlerRef { + /// 정점 ID로 해석한다 — 못 하면 Unknown(usr 없음). + pub fn resolve(&self, ctx: &Ctx) -> Handler { + match self { + HandlerRef::Path { module, segs } => match ctx.resolve_vertex(module, segs) { + Some(id) => Handler::Usr(id), + None => Handler::Unknown, + }, + HandlerRef::Closure(l) => Handler::Closure(l.clone()), + HandlerRef::Unknown => Handler::Unknown, + } + } +} + +/// 핸들러 식 — 경로, `h.layer(..)`·`h.with_state(..)`의 수신자, 클로저. +pub(super) fn handler_ref(module: &str, file: &Path, e: &syn::Expr) -> HandlerRef { + match e { + syn::Expr::Path(p) => HandlerRef::Path { + module: module.to_string(), + segs: crate::harvest::path_segments(&p.path), + }, + syn::Expr::MethodCall(m) => handler_ref(module, file, &m.receiver), + syn::Expr::Paren(p) => handler_ref(module, file, &p.expr), + syn::Expr::Reference(r) => handler_ref(module, file, &r.expr), + syn::Expr::Closure(c) => HandlerRef::Closure(Loc { + file: file.to_path_buf(), + span: c.span(), + }), + _ => HandlerRef::Unknown, + } +} + +/// 결합된 경로 — 리터럴, 알 수 없는 앞부분 뒤의 리터럴(base), 템플릿 불가. +#[derive(Clone, Debug)] +pub(super) enum JPath { + Lit(String), + Base(String), + Dyn(Loc), +} + +impl JPath { + /// 경로 인자에서 만든다. + pub fn of(p: &PathArg) -> JPath { + match p { + PathArg::Lit(s, _) => JPath::Lit(s.clone()), + PathArg::Dyn(l) => JPath::Dyn(l.clone()), + } + } + + /// 접두사(self)에 안쪽 경로를 `glue`로 잇는다. 알 수 없는 조각이 맨 앞이면 + /// 나머지를 base로, 가운데면 템플릿을 포기한다(dynamic, 위치는 `loc`). + pub fn join(&self, inner: &JPath, glue: impl Fn(&str, &str) -> String, loc: &Loc) -> JPath { + match (self, inner) { + (JPath::Lit(p), JPath::Lit(q)) => JPath::Lit(glue(p, q)), + (JPath::Base(p), JPath::Lit(q)) => JPath::Base(glue(p, q)), + (JPath::Dyn(_), JPath::Lit(q)) => JPath::Base(q.clone()), + (JPath::Dyn(l), _) | (_, JPath::Dyn(l)) => JPath::Dyn(l.clone()), + (JPath::Lit(_) | JPath::Base(_), JPath::Base(_)) => JPath::Dyn(loc.clone()), + } + } +} + +/// 추출기가 평가하지 않는 자리(impl·trait 메서드 본문)의 라우터 생성 호출 위치. +/// +/// 추출기는 모듈 수준 함수만 평가한다. 메서드 안에서 `Router::new()`·`App::new()`로 +/// 라우터를 만들면 그 선언을 놓치므로, 조용히 0건이 되지 않게 위치를 센다. +pub(super) fn unevaluated_constructors( + ctx: &Ctx, + krate: &str, + api_crate: &str, + tail: &[&str], +) -> Vec { + struct Finder<'b> { + imports: &'b Imports, + api_crate: &'b str, + tail: &'b [&'b str], + file: &'b Path, + out: Vec, + } + impl syn::visit::Visit<'_> for Finder<'_> { + fn visit_expr_call(&mut self, c: &syn::ExprCall) { + if let syn::Expr::Path(p) = &*c.func { + let segs = crate::harvest::path_segments(&p.path); + if is_api(self.imports, &segs, self.api_crate, self.tail) { + self.out.push(Loc { + file: self.file.to_path_buf(), + span: c.span(), + }); + } + } + syn::visit::visit_expr_call(self, c); + } + } + let mut out = Vec::new(); + for module in ctx.crate_modules(krate) { + let groups = ctx.parts.module_items(&module); + let imports = Imports::of(&groups); + for (file, items) in &groups { + let mut f = Finder { + imports: &imports, + api_crate, + tail, + file, + out: Vec::new(), + }; + for item in *items { + match item { + syn::Item::Impl(i) if !is_test_item(&i.attrs) => { + syn::visit::Visit::visit_item_impl(&mut f, i) + } + syn::Item::Trait(t) if !is_test_item(&t.attrs) => { + syn::visit::Visit::visit_item_trait(&mut f, t) + } + _ => {} + } + } + out.extend(f.out); + } + } + out +} + +/// order group 문자열 — 계약의 256자 상한을 넘으면 앞부분과 지문으로 줄인다. +pub(super) fn order_group(prefix: &str, id: &str) -> String { + let full = format!("{prefix}{id}"); + if full.encode_utf16().count() <= 256 { + return full; + } + let mut h = 0xcbf29ce484222325u64; + for b in full.bytes() { + h = (h ^ u64::from(b)).wrapping_mul(0x100000001b3); + } + // `#` + 16자리 지문을 뺀 나머지를 UTF-16 단위로 채운다(BMP 밖 글자는 2단위). + let head = take_utf16(&full, 256 - 17); + format!("{}#{h:016x}", head.trim_end()) +} + +/// 앞에서부터 UTF-16 코드 단위 `budget` 이하가 되도록 자른다 — 소비자의 길이 상한은 +/// UTF-16 기준이라 문자 수로 자르면 BMP 밖 글자에서 넘친다. +pub(super) fn take_utf16(text: &str, budget: usize) -> String { + let mut used = 0; + text.chars() + .take_while(|c| { + used += c.len_utf16(); + used <= budget + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_groups_and_texts_fit_utf16_budgets() { + let id = "\u{10300}".repeat(300); + let g = order_group("actix:", &id); + assert!( + g.encode_utf16().count() <= 256, + "{}", + g.encode_utf16().count() + ); + assert_eq!(order_group("actix:", "a::b"), "actix:a::b"); + assert_eq!(take_utf16("a\u{10300}b", 2), "a"); + } +} diff --git a/src/source/routes/pattern.rs b/src/source/routes/pattern.rs new file mode 100644 index 0000000..0529855 --- /dev/null +++ b/src/source/routes/pattern.rs @@ -0,0 +1,463 @@ +//! actix-router `ResourceDef` 패턴을 정규 템플릿 세그먼트로 바꾼다. +//! +//! 근거(actix-router 0.5.4 `src/resource.rs`): `{name}`은 `[^/]+`(907), +//! `{name:regex}`는 이름 붙은 그룹에 그대로 들어가 여러 세그먼트와 맞을 수 있고 +//! (956, 문서 140-142), 끝의 `{name}*`는 `.*`(908, 935)다. 한 세그먼트에 정적 글자와 +//! 파라미터를 섞을 수 있고 파라미터 여러 개도 된다(문서 78-80, 1214). 정규식은 +//! 실행하지 않고 흔한 모양(`X+`·`X{n,m}`과 그 연결)만 읽는다 — 읽지 못한 정규식이 +//! `/`와 맞을 수 있는지 증명하지 못하면 템플릿을 포기한다(dynamic). + +use super::template::{Constraint, Seg}; + +/// 해석 결과 — 세그먼트와, 끝 catch-all이 빈 나머지도 받는지. +pub(super) struct Parsed { + pub segs: Vec, + pub empty_tail: bool, +} + +/// 패턴 조각 — 리터럴 글자열 또는 파라미터. +enum Piece { + Lit(String), + Param { regex: Option, tail: bool }, +} + +/// actix 패턴 전체(앞 `/` 포함)를 해석한다. Err는 템플릿으로 쓸 수 없는 사유다. +pub(super) fn parse_actix(raw: &str) -> Result { + let pieces = tokenize(raw)?; + // 조각을 세그먼트로 모은다. 리터럴 안의 `/`가 세그먼트 경계다. + let mut segs: Vec> = vec![Vec::new()]; + for piece in pieces { + match piece { + Piece::Lit(text) => { + let mut parts = text.split('/'); + if let Some(first) = parts.next() { + push_lit(segs.last_mut().expect("at least one segment"), first); + } + for part in parts { + segs.push(Vec::new()); + push_lit(segs.last_mut().expect("just pushed"), part); + } + } + p => segs.last_mut().expect("at least one segment").push(p), + } + } + // 앞 `/`가 만든 빈 첫 세그먼트를 뗀다. + if raw.starts_with('/') { + segs.remove(0); + } else { + return Err("does not start with `/`".to_string()); + } + let n = segs.len(); + let mut out = Vec::with_capacity(n); + let mut empty_tail = false; + for (i, pieces) in segs.into_iter().enumerate() { + let (seg, empty) = segment(pieces, i == n - 1)?; + empty_tail |= empty; + out.push(seg); + } + Ok(Parsed { + segs: out, + empty_tail, + }) +} + +/// 빈 리터럴은 넣지 않는다. +fn push_lit(seg: &mut Vec, text: &str) { + if !text.is_empty() { + seg.push(Piece::Lit(text.to_string())); + } +} + +/// 세그먼트 하나를 템플릿 세그먼트로 — (세그먼트, 빈 끝 변형 필요). +fn segment(pieces: Vec, last: bool) -> Result<(Seg, bool), String> { + let params = pieces + .iter() + .filter(|p| matches!(p, Piece::Param { .. })) + .count(); + if params == 0 { + let text: String = pieces + .into_iter() + .map(|p| match p { + Piece::Lit(t) => t, + Piece::Param { .. } => String::new(), + }) + .collect(); + return Ok((Seg::Lit(text), false)); + } + if params > 1 { + return Err("has more than one parameter in a segment".to_string()); + } + let mut prefix = String::new(); + let mut suffix = String::new(); + let mut param = None; + for p in pieces { + match p { + Piece::Lit(t) if param.is_none() => prefix.push_str(&t), + Piece::Lit(t) => suffix.push_str(&t), + Piece::Param { regex, tail } => param = Some((regex, tail)), + } + } + let (regex, tail) = param.expect("one parameter counted"); + let class = match (tail, regex.as_deref()) { + (true, _) => Class::Multi { empty: true }, + (false, None) => Class::Segment(None), + (false, Some(re)) => classify(re), + }; + let whole = prefix.is_empty() && suffix.is_empty(); + match class { + Class::Segment(constraint) => Ok(( + Seg::Param { + prefix, + suffix, + constraint, + }, + false, + )), + Class::Multi { empty } if whole && last => Ok((Seg::CatchAll, empty)), + Class::Multi { .. } => Err( + "has a parameter that can span segments but is not a whole last segment".to_string(), + ), + Class::Unknown => Err("has a parameter regex the analyzer cannot bound".to_string()), + } +} + +/// 패턴을 리터럴·파라미터 조각으로 나눈다. 파라미터 안 정규식의 중첩 중괄호 +/// (`\d{3}`)를 센다(resource.rs:910-922). 끝의 `}*`는 꼬리 파라미터다(935). +fn tokenize(raw: &str) -> Result, String> { + let mut out = Vec::new(); + let mut lit = String::new(); + let chars: Vec = raw.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] != '{' { + lit.push(chars[i]); + i += 1; + continue; + } + let mut depth = 1; + let mut j = i + 1; + while j < chars.len() && depth > 0 { + match chars[j] { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + j += 1; + } + if depth != 0 { + return Err("has an unclosed `{`".to_string()); + } + let inner: String = chars[i + 1..j - 1].iter().collect(); + let (name, regex) = match inner.split_once(':') { + Some((n, r)) => (n.to_string(), Some(r.to_string())), + None => (inner.clone(), None), + }; + if name.is_empty() { + return Err("has an unnamed parameter".to_string()); + } + let tail = j == chars.len() - 1 && chars[j] == '*'; + if tail && regex.is_some() { + return Err("has a tail parameter with a custom regex (actix panics)".to_string()); + } + if !lit.is_empty() { + out.push(Piece::Lit(std::mem::take(&mut lit))); + } + out.push(Piece::Param { regex, tail }); + i = if tail { j + 1 } else { j }; + } + if !lit.is_empty() { + out.push(Piece::Lit(lit)); + } + Ok(out) +} + +/// 정규식 분류. +#[derive(Debug, PartialEq, Eq)] +enum Class { + /// 한 세그먼트 안에서만 맞는다(빈 값 없음) — 제약(없으면 제약 없음). + Segment(Option), + /// `/`까지 맞을 수 있다 — `empty`면 빈 값도. + Multi { empty: bool }, + /// 읽지 못함. + Unknown, +} + +/// 128비트 ASCII 문자 집합. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Set(u128); + +impl Set { + const EMPTY: Set = Set(0); + const ALL: Set = Set(u128::MAX); + fn range(a: u8, b: u8) -> Set { + (a..=b).fold(Set::EMPTY, |s, c| s.with(c)) + } + fn with(self, c: u8) -> Set { + if c < 128 { + Set(self.0 | (1u128 << c)) + } else { + self + } + } + fn has(self, c: u8) -> bool { + c < 128 && self.0 & (1u128 << c) != 0 + } + fn union(self, o: Set) -> Set { + Set(self.0 | o.0) + } + fn not(self) -> Set { + Set(!self.0) + } + fn subset(self, o: Set) -> bool { + self.0 & !o.0 == 0 + } + fn digits() -> Set { + Set::range(b'0', b'9') + } + fn word() -> Set { + Set::range(b'a', b'z') + .union(Set::range(b'A', b'Z')) + .union(Set::digits()) + .with(b'_') + } + fn space() -> Set { + [b' ', b'\t', b'\n', b'\r', 0x0b, 0x0c] + .iter() + .fold(Set::EMPTY, |s, &c| s.with(c)) + } + /// `/`를 뺀 전체 — 기본 `[^/]+`와 같은 집합. + fn not_slash(self) -> Set { + Set(self.0 & !(1u128 << b'/')) + } + fn hex() -> Set { + Set::digits() + .union(Set::range(b'a', b'f')) + .union(Set::range(b'A', b'F')) + } +} + +/// 연결 원소 하나 — 문자 집합과 반복 범위(최대 None = 무한). +struct Atom { + set: Set, + min: u32, + max: Option, +} + +/// 정규식을 분류한다. 읽는 문법: 문자 클래스(`[..]`·`[^..]`), `\d \w \s \D \W \S`, +/// `.`, 이스케이프·일반 리터럴과 수량자 `+ * ? {n} {n,} {n,m}`의 연결. 그룹·대안· +/// 앵커·플래그가 있으면 Unknown이다. +fn classify(re: &str) -> Class { + let Some(atoms) = atoms(re) else { + return Class::Unknown; + }; + if atoms.is_empty() { + return Class::Unknown; + } + let slash = atoms.iter().any(|a| a.set.has(b'/')); + let min: u32 = atoms.iter().map(|a| a.min).sum(); + if slash { + // `.*`·`.+`처럼 원소 하나가 전부일 때만 끝 catch-all로 읽는다. + return match atoms.as_slice() { + [a] if a.max.is_none() && a.set == Set::ALL && a.min <= 1 => { + Class::Multi { empty: a.min == 0 } + } + _ => Class::Unknown, + }; + } + if min == 0 { + // 빈 값과 맞는 파라미터는 `{}`(비어 있지 않은 세그먼트)로 쓸 수 없다. + return Class::Unknown; + } + let all: Set = atoms.iter().fold(Set::EMPTY, |s, a| s.union(a.set)); + let slug = Set::word().with(b'-'); + let constraint = if is_uuid(&atoms) { + Some(Constraint::Uuid) + } else if all.subset(Set::digits()) { + Some(Constraint::Int) + } else if all.subset(slug) { + Some(Constraint::Slug) + } else if atoms.len() == 1 && all == Set::ALL.not_slash() { + None + } else { + Some(Constraint::Regex(re.to_string())) + }; + Class::Segment(constraint) +} + +/// 하이픈 있는 8-4-4-4-12 hex 모양인가. +fn is_uuid(atoms: &[Atom]) -> bool { + let want = [8, 0, 4, 0, 4, 0, 4, 0, 12]; + atoms.len() == want.len() + && atoms.iter().zip(want).all(|(a, n)| { + if n == 0 { + a.set == Set::EMPTY.with(b'-') && a.min == 1 && a.max == Some(1) + } else { + a.set.subset(Set::hex()) && a.min == n && a.max == Some(n) + } + }) +} + +/// 정규식을 원소 연결로 읽는다. 읽지 못하면 None. +fn atoms(re: &str) -> Option> { + let b = re.as_bytes(); + let mut i = 0; + let mut out = Vec::new(); + while i < b.len() { + let (set, next) = match b[i] { + b'[' => class(b, i)?, + b'\\' => (escape(*b.get(i + 1)?)?, i + 2), + b'.' => (Set::ALL, i + 1), + b'(' | b')' | b'|' | b'^' | b'$' | b'*' | b'+' | b'?' | b'{' | b'}' => return None, + c if c.is_ascii() => (Set::EMPTY.with(c), i + 1), + _ => return None, + }; + i = next; + let (min, max, after) = quantifier(b, i)?; + i = after; + out.push(Atom { set, min, max }); + } + Some(out) +} + +/// 이스케이프 하나의 집합. +fn escape(c: u8) -> Option { + Some(match c { + b'd' => Set::digits(), + b'w' => Set::word(), + b's' => Set::space(), + b'D' => Set::digits().not(), + b'W' => Set::word().not(), + b'S' => Set::space().not(), + c if c.is_ascii_punctuation() => Set::EMPTY.with(c), + _ => return None, + }) +} + +/// `[..]` 클래스 — (집합, 다음 위치). +fn class(b: &[u8], start: usize) -> Option<(Set, usize)> { + let mut i = start + 1; + let negate = b.get(i) == Some(&b'^'); + if negate { + i += 1; + } + let mut set = Set::EMPTY; + let mut first = true; + loop { + let c = *b.get(i)?; + if c == b']' && !first { + i += 1; + break; + } + first = false; + let (lo, next) = match c { + b'\\' => { + let e = *b.get(i + 1)?; + if e.is_ascii_alphabetic() { + set = set.union(escape(e)?); + i += 2; + continue; + } + (e, i + 2) + } + b'[' => return None, + c if c.is_ascii() => (c, i + 1), + _ => return None, + }; + i = next; + if b.get(i) == Some(&b'-') && b.get(i + 1).is_some_and(|&c| c != b']') { + let hi = *b.get(i + 1)?; + if hi == b'\\' || !hi.is_ascii() || hi < lo { + return None; + } + set = set.union(Set::range(lo, hi)); + i += 2; + } else { + set = set.with(lo); + } + } + Some((if negate { set.not() } else { set }, i)) +} + +/// 수량자 — (최소, 최대, 다음 위치). 게으른 `?` 접미사는 같은 범위다. +fn quantifier(b: &[u8], i: usize) -> Option<(u32, Option, usize)> { + let (min, max, mut next) = match b.get(i) { + Some(b'+') => (1, None, i + 1), + Some(b'*') => (0, None, i + 1), + Some(b'?') => (0, Some(1), i + 1), + Some(b'{') => { + let close = i + b[i..].iter().position(|&c| c == b'}')?; + let body = std::str::from_utf8(&b[i + 1..close]).ok()?; + let (lo, hi) = match body.split_once(',') { + None => { + let n = body.parse().ok()?; + (n, Some(n)) + } + Some((a, "")) => (a.parse().ok()?, None), + Some((a, c)) => (a.parse().ok()?, Some(c.parse().ok()?)), + }; + (lo, hi, close + 1) + } + _ => (1, Some(1), i), + }; + if next > i && b.get(next) == Some(&b'?') { + next += 1; + } + Some((min, max, next)) +} + +#[cfg(test)] +mod tests { + use super::super::template::render; + use super::*; + + fn t(raw: &str) -> String { + match parse_actix(raw) { + Ok(p) => format!( + "{}{}", + render(&p.segs), + if p.empty_tail { " +empty" } else { "" } + ), + Err(e) => format!("ERR {e}"), + } + } + + #[test] + fn actix_patterns() { + assert_eq!(t("/items/{id}"), "/items/{}"); + assert_eq!(t("/files/{name}.json"), "/files/{}.json"); + assert_eq!(t("/files/{tail}*"), "/files/{**} +empty"); + assert_eq!(t("/files/{tail:.*}"), "/files/{**} +empty"); + assert_eq!(t("/files/{tail:.+}"), "/files/{**}"); + assert!(t("/v{a}.{b}").starts_with("ERR")); + assert!(t("/user{tail}*").starts_with("ERR")); + assert!(t("/a/{x:(foo|bar)}").starts_with("ERR")); + assert_eq!(t("/"), "/"); + assert_eq!(t("/a/"), "/a/"); + } + + #[test] + fn regex_constraints() { + assert_eq!(classify(r"\d+"), Class::Segment(Some(Constraint::Int))); + assert_eq!( + classify(r"[0-9]{1,6}"), + Class::Segment(Some(Constraint::Int)) + ); + assert_eq!( + classify(r"[a-z0-9-]+"), + Class::Segment(Some(Constraint::Slug)) + ); + assert_eq!(classify(r"\w+"), Class::Segment(Some(Constraint::Slug))); + assert_eq!(classify(r"[^/]+"), Class::Segment(None)); + assert_eq!( + classify(r"[^/.]+"), + Class::Segment(Some(Constraint::Regex(r"[^/.]+".into()))) + ); + assert_eq!( + classify(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"), + Class::Segment(Some(Constraint::Uuid)) + ); + assert_eq!(classify(r"\d*"), Class::Unknown); + assert_eq!(classify(r"[a-z/]+"), Class::Unknown); + assert_eq!(classify(r"\S+"), Class::Unknown); + } +} diff --git a/src/source/routes/template.rs b/src/source/routes/template.rs new file mode 100644 index 0000000..594dc29 --- /dev/null +++ b/src/source/routes/template.rs @@ -0,0 +1,280 @@ +//! isthmus 정규 경로 템플릿 — 문법 검사·URI 경로 정규화·세그먼트 조립. +//! +//! 정본은 isthmus docs/GRAPH-EXCHANGE.md "정규 경로 템플릿"과 공유 벡터 +//! `http-template`(`template.grammar`·`template.normalize`)이다. isthmus는 문법만 +//! 검증하고 다시 정규화하지 않으므로, 생산자가 소비자와 같은 판정을 내야 문서가 +//! 거부되지 않는다. 이 모듈은 syn을 모른다 — 프레임워크 경로 문법은 각 추출기가 +//! 여기의 [`Seg`]로 바꿔 넘긴다. + +/// 템플릿 최대 길이(UTF-16 코드 단위) — 소비자의 `too-long` 기준이다. +pub const MAX_TEMPLATE_LENGTH: usize = 2048; + +/// 정규 템플릿이면 None, 아니면 소비자와 같은 거부 사유 코드를 돌려준다. +pub fn template_problem(template: &str) -> Option<&'static str> { + if template.encode_utf16().count() > MAX_TEMPLATE_LENGTH { + return Some("too-long"); + } + let Some(rest) = template.strip_prefix('/') else { + return Some("not-rooted"); + }; + let raw: Vec<&str> = rest.split('/').collect(); + for (i, seg) in raw.iter().enumerate() { + if *seg == "{**}" { + if i != raw.len() - 1 { + return Some("catch-all-not-last"); + } + continue; + } + if let Some(problem) = segment_problem(seg) { + return Some(problem); + } + } + None +} + +/// 세그먼트 하나의 거부 사유 — isthmus `parseSegment`와 같은 순서로 판정한다. +fn segment_problem(seg: &str) -> Option<&'static str> { + let bytes = seg.as_bytes(); + let mut param = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'{' => { + if seg[i..].starts_with("{**}") { + return Some("catch-all-partial"); + } + if bytes.get(i + 1) != Some(&b'}') { + return Some("stray-brace"); + } + if param { + return Some("multiple-parameters"); + } + param = true; + i += 2; + } + b'}' => return Some("stray-brace"), + b'%' => { + if let Some(problem) = percent_problem(bytes, i) { + return Some(problem); + } + i += 3; + } + c if is_pchar_literal(c) => i += 1, + _ => return Some("invalid-character"), + } + } + None +} + +/// `%XX` 하나의 거부 사유 — 대문자 hex여야 하고 unreserved를 인코딩하면 안 된다. +fn percent_problem(bytes: &[u8], at: usize) -> Option<&'static str> { + let (Some(&h), Some(&l)) = (bytes.get(at + 1), bytes.get(at + 2)) else { + return Some("malformed-percent"); + }; + if !h.is_ascii_hexdigit() || !l.is_ascii_hexdigit() { + return Some("malformed-percent"); + } + if h.is_ascii_lowercase() || l.is_ascii_lowercase() { + return Some("lowercase-percent-hex"); + } + if is_unreserved(hex_value(h) * 16 + hex_value(l)) { + return Some("encoded-unreserved"); + } + None +} + +/// RFC 3986 unreserved 문자다 — 인코딩하지 않는다. +fn is_unreserved(c: u8) -> bool { + c.is_ascii_alphanumeric() || matches!(c, b'-' | b'.' | b'_' | b'~') +} + +/// `%`를 뺀 pchar 리터럴 문자다(unreserved·sub-delims·`:`·`@`). +fn is_pchar_literal(c: u8) -> bool { + is_unreserved(c) + || matches!( + c, + b'!' | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + +/// hex 숫자 하나의 값 — 호출자가 `is_ascii_hexdigit`를 먼저 확인한다. +fn hex_value(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => c - b'A' + 10, + } +} + +/// URI 경로 문자열을 정규 템플릿 리터럴로 정규화한다(`template.normalize`). +/// +/// `%XX`는 대문자 hex로 쓰고 unreserved면 디코드한다. pchar도 `/`도 아닌 문자 +/// (공백·중괄호·ASCII 밖 문자 등)는 UTF-8 바이트마다 `%XX`로 인코딩한다. 중복 +/// 슬래시·끝 슬래시·인코딩된 `/`(`%2F`)는 보존한다. 형식이 깨진 `%`는 글자 +/// 그대로의 `%`로 보고 `%25`로 쓴다. +pub fn normalize_uri_path(path: &str) -> String { + let bytes = path.as_bytes(); + let mut out = String::with_capacity(path.len()); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c == b'%' { + let pair = (bytes.get(i + 1), bytes.get(i + 2)); + if let (Some(&h), Some(&l)) = pair { + if h.is_ascii_hexdigit() && l.is_ascii_hexdigit() { + let value = hex_value(h) * 16 + hex_value(l); + push_byte(&mut out, value, true); + i += 3; + continue; + } + } + out.push_str("%25"); + i += 1; + continue; + } + push_byte(&mut out, c, false); + i += 1; + } + out +} + +/// 바이트 하나를 정규형으로 쓴다. `encoded`면 원문이 `%XX`였다는 뜻이라 +/// unreserved만 디코드하고 나머지(`%2F` 포함)는 인코딩을 유지한다. +fn push_byte(out: &mut String, c: u8, encoded: bool) { + let literal = if encoded { + is_unreserved(c) + } else { + c == b'/' || is_pchar_literal(c) + }; + if literal { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } +} + +/// 프레임워크 경로를 해석한 세그먼트 하나다. 리터럴 조각은 프레임워크가 요청 +/// 경로와 비교하는 원문 그대로 담고, 렌더링할 때 [`normalize_uri_path`]를 거친다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Seg { + /// 파라미터 없는 세그먼트. + Lit(String), + /// 파라미터 하나를 품은 세그먼트 — 앞뒤 리터럴이 비면 세그먼트 전체 `{}`. + Param { + prefix: String, + suffix: String, + constraint: Option, + }, + /// 마지막 세그먼트 전체의 끝 catch-all(세그먼트 1개 이상). + CatchAll, +} + +/// `paramConstraints` 항목의 종류다. 닫힌 종류(int·uuid·slug)만 소비자가 평가한다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Constraint { + Int, + Uuid, + Slug, + /// 평가하지 않는 정규식 — 원문은 정보용 `pattern`이다. + Regex(String), +} + +impl Constraint { + /// 계약의 kind 문자열이다. + pub fn kind(&self) -> &'static str { + match self { + Constraint::Int => "int", + Constraint::Uuid => "uuid", + Constraint::Slug => "slug", + Constraint::Regex(_) => "regex", + } + } +} + +/// 세그먼트 목록을 정규 템플릿 문자열로 쓴다. 세그먼트가 없으면 루트 `/`다. +pub fn render(segs: &[Seg]) -> String { + if segs.is_empty() { + return "/".to_string(); + } + let mut out = String::new(); + for seg in segs { + out.push('/'); + match seg { + Seg::Lit(text) => out.push_str(&encode_segment_text(text)), + Seg::Param { prefix, suffix, .. } => { + out.push_str(&encode_segment_text(prefix)); + out.push_str("{}"); + out.push_str(&encode_segment_text(suffix)); + } + Seg::CatchAll => out.push_str("{**}"), + } + } + out +} + +/// 세그먼트 안 리터럴 조각을 정규화한다. `/`는 세그먼트 경계라 이 자리에 올 수 +/// 없으므로 `%2F`로 쓴다(프레임워크가 한 세그먼트로 비교한 문자다). +fn encode_segment_text(text: &str) -> String { + normalize_uri_path(text).replace('/', "%2F") +} + +/// `/a/b/` 같은 원문 경로를 리터럴 세그먼트로 나눈다(앞 `/` 하나는 뗀다). +/// 빈 경로와 `/`는 빈 세그먼트 하나(루트)다. +pub fn literal_segments(path: &str) -> Vec { + let body = path.strip_prefix('/').unwrap_or(path); + body.split('/').map(|s| Seg::Lit(s.to_string())).collect() +} + +/// 세그먼트 목록의 `paramConstraints`(세그먼트 인덱스, 제약)다. +pub fn constraints(segs: &[Seg]) -> Vec<(usize, Constraint)> { + segs.iter() + .enumerate() + .filter_map(|(i, s)| match s { + Seg::Param { + constraint: Some(c), + .. + } => Some((i, c.clone())), + _ => None, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_normalizes_literals_and_params() { + let segs = vec![ + Seg::Lit("caf\u{e9}".into()), + Seg::Param { + prefix: "v".into(), + suffix: String::new(), + constraint: None, + }, + Seg::Lit("{x}".into()), + Seg::CatchAll, + ]; + assert_eq!(render(&segs), "/caf%C3%A9/v{}/%7Bx%7D/{**}"); + assert_eq!(render(&[]), "/"); + assert_eq!(render(&literal_segments("/a//b/")), "/a//b/"); + assert_eq!(template_problem(&render(&segs)), None); + } + + #[test] + fn malformed_percent_is_a_literal_percent() { + assert_eq!(normalize_uri_path("/a%2"), "/a%252"); + assert_eq!(normalize_uri_path("/%zz"), "/%25zz"); + } +} diff --git a/src/source/routes/validate.rs b/src/source/routes/validate.rs new file mode 100644 index 0000000..dfa7b49 --- /dev/null +++ b/src/source/routes/validate.rs @@ -0,0 +1,235 @@ +//! 생산 문서 자체 검사 — isthmus 파서가 거부할 `order`·`limitationScopes`를 내기 +//! 전에 잡는다. +//! +//! 규칙의 정본은 isthmus `src/exchange/parse.ts`(`validateRouteOrder`· +//! `validateRouteDocumentFacts`·`validateRouteOrderGroups`)와 스코프 검증이며, 공유 +//! 벡터 `dispatch.validate`·`scope.validate`로 같은 판정을 확인한다. JSON 값 위에서 +//! 동작해 벡터(소수·문자열 index 같은 잘못된 입력)와 생산 문서를 한 구현으로 본다. + +use super::template::template_problem; +use serde_json::Value; +use std::collections::BTreeMap; + +/// `order.group` 최대 길이(UTF-16 코드 단위) — isthmus `MAX_ROUTE_ORDER_GROUP_LENGTH`. +const MAX_GROUP_LENGTH: usize = 256; + +/// JS `Number.MAX_SAFE_INTEGER` — index는 안전 정수여야 한다. +const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + +/// isthmus가 받는 route 동사다(`ANY`는 스코프 methods에 올 수 없다). +const HTTP_METHODS: &[&str] = &[ + "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", +]; + +/// http 문서(`dispatch`·`service`·`facts`)의 `order`·catch-all 접두사 규칙 위반을 +/// 찾는다. 문제가 없으면 None이다. +pub fn order_problem(document: &Value) -> Option { + let dispatch = document.get("dispatch"); + let facts = document.get("facts")?.as_array()?; + for (i, fact) in facts.iter().enumerate() { + if let Some(order) = fact.get("order") { + if let Some(problem) = order_value_problem(order, dispatch) { + return Some(format!("{problem} at index {i}")); + } + } + } + catch_all_prefix_problem(document, facts).or_else(|| group_problem(document, facts)) +} + +/// `order` 값 하나의 형식 위반이다. +fn order_value_problem(order: &Value, dispatch: Option<&Value>) -> Option<&'static str> { + if dispatch.and_then(Value::as_str) != Some("registration-order") { + return Some("order requires dispatch registration-order"); + } + let Some(obj) = order.as_object() else { + return Some("order must be an object"); + }; + if obj.keys().any(|k| k != "group" && k != "index") { + return Some("order has keys other than group and index"); + } + let group_ok = obj + .get("group") + .and_then(Value::as_str) + .is_some_and(is_group_string); + if !group_ok { + return Some("order group must be a safe non-empty string"); + } + let index_ok = obj + .get("index") + .and_then(Value::as_u64) + .is_some_and(|n| n <= MAX_SAFE_INTEGER); + if !index_ok { + return Some("order index must be a non-negative safe integer"); + } + None +} + +/// group 문자열 규칙 — 비어 있지 않고, 제어 문자·앞뒤 공백이 없고, 256자 이하. +fn is_group_string(group: &str) -> bool { + !group.trim().is_empty() + && group.trim() == group + && group.encode_utf16().count() <= MAX_GROUP_LENGTH + && !group.chars().any(is_control) +} + +/// isthmus `controlCharacterPattern`(C0·DEL·C1·U+2028·U+2029)과 같은 판정이다. +fn is_control(c: char) -> bool { + matches!(c, '\u{0}'..='\u{1f}' | '\u{7f}'..='\u{9f}' | '\u{2028}' | '\u{2029}') +} + +/// 사실의 유효 service(사실 값, 없으면 문서 값)다. +fn effective_service<'a>(document: &'a Value, fact: &'a Value) -> Option<&'a str> { + fact.get("service") + .or_else(|| document.get("service")) + .and_then(Value::as_str) +} + +/// catch-all 접두사 decl은 같은 method·usr·service·order의 원본 `{**}` decl이 있어야 한다. +fn catch_all_prefix_problem(document: &Value, facts: &[Value]) -> Option { + let key = |fact: &Value, channel: &str| { + serde_json::json!([ + fact.get("method"), + fact.pointer("/symbol/usr"), + channel, + effective_service(document, fact), + fact.get("order"), + ]) + .to_string() + }; + let originals: std::collections::BTreeSet = facts + .iter() + .filter(|f| { + f.get("dynamic") != Some(&Value::Bool(true)) && f.get("catchAllPrefix").is_none() + }) + .filter_map(|f| f.get("channel").and_then(Value::as_str).map(|c| key(f, c))) + .collect(); + for (i, fact) in facts.iter().enumerate() { + if fact.get("catchAllPrefix") != Some(&Value::Bool(true)) { + continue; + } + let channel = fact.get("channel").and_then(Value::as_str).unwrap_or(""); + let original = if channel == "/" { + "/{**}".to_string() + } else { + format!("{channel}/{{**}}") + }; + if !originals.contains(&key(fact, &original)) { + return Some(format!( + "catch-all prefix declaration has no matching {{**}} declaration at index {i}" + )); + } + } + None +} + +/// 한 (group, index)는 한 위치, 한 group은 한 service다. +fn group_problem(document: &Value, facts: &[Value]) -> Option { + let mut registrations: BTreeMap = BTreeMap::new(); + let mut services: BTreeMap> = BTreeMap::new(); + for (i, fact) in facts.iter().enumerate() { + let Some(order) = fact.get("order") else { + continue; + }; + let group = order.get("group").and_then(Value::as_str).unwrap_or(""); + let service = effective_service(document, fact).map(str::to_string); + if let Some(seen) = services.get(group) { + if *seen != service { + return Some(format!( + "order group is shared by different services at index {i}" + )); + } + } + services.insert(group.to_string(), service); + let reg = serde_json::json!([group, order.get("index")]).to_string(); + let loc = serde_json::json!([ + fact.pointer("/location/path"), + fact.pointer("/location/line"), + fact.pointer("/location/column"), + ]) + .to_string(); + if let Some(seen) = registrations.get(®) { + if *seen != loc { + return Some(format!( + "order index is shared by registrations at different locations at index {i}" + )); + } + } + registrations.insert(reg, loc); + } + None +} + +/// http `limitationScopes` 항목 하나의 위반이다. 문제가 없으면 None. +pub fn scope_problem(scope: &Value) -> Option<&'static str> { + let Some(obj) = scope.as_object() else { + return Some("scope must be an object"); + }; + const KEYS: &[&str] = &[ + "limitationIndex", + "templates", + "templatePrefixes", + "templateSuffixes", + "methods", + ]; + if obj.keys().any(|k| !KEYS.contains(&k.as_str())) { + return Some("unknown scope key"); + } + if !obj.get("limitationIndex").is_some_and(Value::is_u64) { + return Some("limitationIndex must be a non-negative integer"); + } + let paths = ["templates", "templatePrefixes", "templateSuffixes"]; + if !paths.iter().any(|k| obj.contains_key(*k)) { + return Some("scope needs a path field"); + } + for field in paths { + if let Some(value) = obj.get(field) { + if let Some(problem) = path_field_problem(field, value) { + return Some(problem); + } + } + } + obj.get("methods").and_then(methods_problem) +} + +/// 경로 필드 하나 — 비어 있지 않은 정규 템플릿 배열, 접두사·접미사 제한. +fn path_field_problem(field: &str, value: &Value) -> Option<&'static str> { + let Some(items) = value.as_array().filter(|a| !a.is_empty()) else { + return Some("scope path field must be a non-empty array"); + }; + for item in items { + let Some(t) = item.as_str() else { + return Some("scope path element must be a string"); + }; + if template_problem(t).is_some() { + return Some("scope path element is not a canonical template"); + } + let catch_all = t.split('/').any(|s| s == "{**}"); + match field { + "templatePrefixes" if catch_all || (t != "/" && t.ends_with('/')) => { + return Some("template prefix must not end with / or contain {**}") + } + "templateSuffixes" if catch_all || t == "/" => { + return Some("template suffix must not be / or contain {**}") + } + _ => {} + } + } + None +} + +/// `methods` — 중복 없는 HTTP 동사(`ANY` 제외)의 비어 있지 않은 배열. +fn methods_problem(value: &Value) -> Option<&'static str> { + let Some(items) = value.as_array().filter(|a| !a.is_empty()) else { + return Some("scope methods must be a non-empty array"); + }; + let mut seen = std::collections::BTreeSet::new(); + for item in items { + let ok = item + .as_str() + .is_some_and(|m| HTTP_METHODS.contains(&m) && seen.insert(m)); + if !ok { + return Some("scope methods must be distinct HTTP verbs"); + } + } + None +} diff --git a/tests/fixture-routes/actix/Cargo.lock b/tests/fixture-routes/actix/Cargo.lock new file mode 100644 index 0000000..1a419af --- /dev/null +++ b/tests/fixture-routes/actix/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-web" +version = "4.15.0" + +[[package]] +name = "actix_app" +version = "0.1.0" +dependencies = [ + "actix-web", +] diff --git a/tests/fixture-routes/actix/Cargo.toml b/tests/fixture-routes/actix/Cargo.toml new file mode 100644 index 0000000..89e95e8 --- /dev/null +++ b/tests/fixture-routes/actix/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["actix_app"] +resolver = "2" diff --git a/tests/fixture-routes/actix/actix_app/Cargo.toml b/tests/fixture-routes/actix/actix_app/Cargo.toml new file mode 100644 index 0000000..8690b31 --- /dev/null +++ b/tests/fixture-routes/actix/actix_app/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "actix_app" +version = "0.1.0" +edition = "2021" +publish = false + +# actix-web은 버전 판정용 스텁이다. 같은 lib.rs를 진짜 actix-web 4.15.0으로 +# 컴파일해 요청을 보내는 오라클은 experiments/routes-oracle에 있다. +[dependencies] +actix-web = { path = "../../stubs/actix-web" } diff --git a/tests/fixture-routes/actix/actix_app/src/handlers.rs b/tests/fixture-routes/actix/actix_app/src/handlers.rs new file mode 100644 index 0000000..ae7baaa --- /dev/null +++ b/tests/fixture-routes/actix/actix_app/src/handlers.rs @@ -0,0 +1,95 @@ +//! 핸들러 — 본문은 자기 정점 ID다. + +use actix_web::{get, route, routes, web, HttpResponse, Responder}; + +#[get("/")] +pub async fn index() -> impl Responder { + "actix_app::handlers::index" +} + +pub async fn ping() -> impl Responder { + "actix_app::handlers::ping" +} + +pub async fn multi() -> impl Responder { + "actix_app::handlers::multi" +} + +pub async fn files() -> impl Responder { + "actix_app::handlers::files" +} + +pub async fn cfg_post() -> impl Responder { + "actix_app::handlers::cfg_post" +} + +#[routes] +#[get("/r1")] +#[post("/r2")] +pub async fn routes_multi() -> impl Responder { + "actix_app::handlers::routes_multi" +} + +#[route("/m", method = "GET", method = "PUT")] +pub async fn route_macro() -> impl Responder { + "actix_app::handlers::route_macro" +} + +#[get("/status")] +pub async fn v1_status() -> impl Responder { + "actix_app::handlers::v1_status" +} + +/// 어느 App에도 등록하지 않은 매크로 핸들러 — base 앵커로 나온다. +#[get("/lonely")] +pub async fn lonely() -> impl Responder { + "actix_app::handlers::lonely" +} + +/// `.configure(config)`로 스코프에 끼워 넣는 등록. +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(web::resource("/cfg").route(web::post().to(cfg_post))); +} + +/// 항목 — 정수 id가 슬러그보다 먼저 등록된다(등록 순서 디스패치). +pub mod items { + use actix_web::{get, post, Responder}; + + #[get("/items")] + pub async fn list() -> impl Responder { + "actix_app::handlers::items::list" + } + + #[post("/items")] + pub async fn create() -> impl Responder { + "actix_app::handlers::items::create" + } + + #[get("/items/{id:\\d+}")] + pub async fn show() -> impl Responder { + "actix_app::handlers::items::show" + } + + #[get("/items/{slug}")] + pub async fn by_slug() -> impl Responder { + "actix_app::handlers::items::by_slug" + } +} + +/// 한 리소스의 라우트 수준 method 가드. +pub mod users { + use actix_web::Responder; + + pub async fn get() -> impl Responder { + "actix_app::handlers::users::get" + } + + pub async fn delete() -> impl Responder { + "actix_app::handlers::users::delete" + } +} + +/// HttpResponse를 쓰는 핸들러가 없으면 경고가 나므로 하나 둔다. +pub async fn unused_response() -> HttpResponse { + HttpResponse::Ok().body("actix_app::handlers::unused_response") +} diff --git a/tests/fixture-routes/actix/actix_app/src/lib.rs b/tests/fixture-routes/actix/actix_app/src/lib.rs new file mode 100644 index 0000000..001f605 --- /dev/null +++ b/tests/fixture-routes/actix/actix_app/src/lib.rs @@ -0,0 +1,47 @@ +//! 합성 actix-web 4 서버 — 매크로·리소스·스코프·설정 함수·경로 정규화를 덮는다. +//! 핸들러는 자기 정점 ID를 응답 본문으로 돌려준다. + +pub mod handlers; + +use actix_web::body::MessageBody; +use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::middleware::NormalizePath; +use actix_web::{guard, web, App, Error}; + +/// 서빙되는 App(main.rs의 `HttpServer::new`가 부른다). 등록 순서가 디스패치 순서다. +pub fn app() -> App< + impl ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +> { + App::new() + .wrap(NormalizePath::trim()) + .service(handlers::index) + .service( + web::scope("/api") + .service(handlers::items::list) + .service(handlers::items::create) + .service(handlers::items::show) + .service(handlers::items::by_slug) + .route("/ping", web::get().to(handlers::ping)) + .service( + web::resource("/users/{id}") + .route(web::get().to(handlers::users::get)) + .route(web::delete().to(handlers::users::delete)), + ) + .service(web::resource(["/a", "/b"]).to(handlers::multi)) + .configure(handlers::config), + ) + .service(web::resource("/files/{tail}*").to(handlers::files)) + .service(handlers::routes_multi) + .service(handlers::route_macro) + .service( + web::scope("/v1") + .guard(guard::Header("x-api", "1")) + .service(handlers::v1_status), + ) +} diff --git a/tests/fixture-routes/actix/actix_app/src/main.rs b/tests/fixture-routes/actix/actix_app/src/main.rs new file mode 100644 index 0000000..e5b6e3c --- /dev/null +++ b/tests/fixture-routes/actix/actix_app/src/main.rs @@ -0,0 +1,9 @@ +//! 서버 진입점. + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + actix_web::HttpServer::new(actix_app::app) + .bind(("127.0.0.1", 8080))? + .run() + .await +} diff --git a/tests/fixture-routes/axum07/Cargo.lock b/tests/fixture-routes/axum07/Cargo.lock new file mode 100644 index 0000000..3d25433 --- /dev/null +++ b/tests/fixture-routes/axum07/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "axum" +version = "0.7.9" + +[[package]] +name = "axum07_app" +version = "0.1.0" +dependencies = [ + "axum", +] diff --git a/tests/fixture-routes/axum07/Cargo.toml b/tests/fixture-routes/axum07/Cargo.toml new file mode 100644 index 0000000..5e92eb2 --- /dev/null +++ b/tests/fixture-routes/axum07/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["axum07_app"] +resolver = "2" diff --git a/tests/fixture-routes/axum07/axum07_app/Cargo.toml b/tests/fixture-routes/axum07/axum07_app/Cargo.toml new file mode 100644 index 0000000..1eb55f5 --- /dev/null +++ b/tests/fixture-routes/axum07/axum07_app/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "axum07_app" +version = "0.1.0" +edition = "2021" +publish = false + +# axum 0.7 경로 문법(`:id`·`*rest`) fixture — axum은 버전 판정용 스텁이다. +[dependencies] +axum = { path = "../../stubs/axum-0.7" } diff --git a/tests/fixture-routes/axum07/axum07_app/src/lib.rs b/tests/fixture-routes/axum07/axum07_app/src/lib.rs new file mode 100644 index 0000000..d30d92c --- /dev/null +++ b/tests/fixture-routes/axum07/axum07_app/src/lib.rs @@ -0,0 +1,62 @@ +//! 합성 axum 0.7 서버 — 0.7 경로 문법과 서비스 등록을 덮는다. + +use axum::body::Body; +use axum::response::Response; +use axum::routing::{get, post}; +use axum::Router; +use std::convert::Infallible; + +/// 서빙되는 라우터. +pub fn app() -> Router { + let mut router = Router::new() + .route("/items/:id", get(items_show).patch(items_patch)) + .route("/files/*path", get(files)) + .route("/user_:id", get(user_prefixed)) + .route("/f/:name.json", get(json_named)) + .route("/orders/:id/lines", get(order_lines)) + .route("/x_:id/y", get(x_prefixed)); + router = router.nest("/api", api()); + router + .route_service("/svc", tower::service_fn(svc)) + .nest_service("/static", tower::service_fn(svc)) +} + +/// `/api` 아래 — 안쪽 `/`는 접두사 자체(`/api`)가 된다. +fn api() -> Router { + Router::new() + .route("/", get(api_root)) + .route("/echo", post(api_echo)) +} + +/// tower 서비스 — 핸들러 함수가 아니라 usr가 없다. +async fn svc(_req: axum::extract::Request) -> Result { + Ok(Response::new(Body::from("service"))) +} + +pub async fn items_show() -> &'static str { + "axum07_app::items_show" +} +pub async fn items_patch() -> &'static str { + "axum07_app::items_patch" +} +pub async fn files() -> &'static str { + "axum07_app::files" +} +pub async fn user_prefixed() -> &'static str { + "axum07_app::user_prefixed" +} +pub async fn json_named() -> &'static str { + "axum07_app::json_named" +} +pub async fn x_prefixed() -> &'static str { + "axum07_app::x_prefixed" +} +pub async fn order_lines() -> &'static str { + "axum07_app::order_lines" +} +pub async fn api_root() -> &'static str { + "axum07_app::api_root" +} +pub async fn api_echo() -> &'static str { + "axum07_app::api_echo" +} diff --git a/tests/fixture-routes/axum07/axum07_app/src/main.rs b/tests/fixture-routes/axum07/axum07_app/src/main.rs new file mode 100644 index 0000000..6b96ed4 --- /dev/null +++ b/tests/fixture-routes/axum07/axum07_app/src/main.rs @@ -0,0 +1,9 @@ +//! 0.7 서빙 — `into_make_service`로 넘긴 라우터가 루트다. + +#[tokio::main] +async fn main() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); + axum::serve(listener, axum07_app::app().into_make_service()) + .await + .unwrap(); +} diff --git a/tests/fixture-routes/axum08/Cargo.lock b/tests/fixture-routes/axum08/Cargo.lock new file mode 100644 index 0000000..e415295 --- /dev/null +++ b/tests/fixture-routes/axum08/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "axum" +version = "0.8.9" + +[[package]] +name = "axum_app" +version = "0.1.0" +dependencies = [ + "axum", +] diff --git a/tests/fixture-routes/axum08/Cargo.toml b/tests/fixture-routes/axum08/Cargo.toml new file mode 100644 index 0000000..526212e --- /dev/null +++ b/tests/fixture-routes/axum08/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["axum_app"] +resolver = "2" diff --git a/tests/fixture-routes/axum08/axum_app/Cargo.toml b/tests/fixture-routes/axum08/axum_app/Cargo.toml new file mode 100644 index 0000000..6b8cca8 --- /dev/null +++ b/tests/fixture-routes/axum08/axum_app/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "axum_app" +version = "0.1.0" +edition = "2021" +publish = false + +# axum은 버전 판정용 스텁이다(../../stubs). 같은 lib.rs를 진짜 axum 0.8.9로 +# 컴파일해 요청을 보내는 오라클은 experiments/routes-oracle에 있다. +[dependencies] +axum = { path = "../../stubs/axum-0.8" } diff --git a/tests/fixture-routes/axum08/axum_app/src/handlers.rs b/tests/fixture-routes/axum08/axum_app/src/handlers.rs new file mode 100644 index 0000000..1604e30 --- /dev/null +++ b/tests/fixture-routes/axum08/axum_app/src/handlers.rs @@ -0,0 +1,87 @@ +//! 핸들러 — 본문은 자기 정점 ID다. + +pub async fn root() -> &'static str { + "axum_app::handlers::root" +} +pub async fn health() -> &'static str { + "axum_app::handlers::health" +} +/// HEAD 응답은 본문이 지워지므로 핸들러 ID를 헤더로도 싣는다. +pub async fn health_head() -> ([(&'static str, &'static str); 1], &'static str) { + ( + [("x-handler", "axum_app::handlers::health_head")], + "axum_app::handlers::health_head", + ) +} +pub async fn tag_prefixed() -> &'static str { + "axum_app::handlers::tag_prefixed" +} +pub async fn status() -> &'static str { + "axum_app::handlers::status" +} +pub async fn status_literal() -> &'static str { + "axum_app::handlers::status_literal" +} +pub async fn search() -> &'static str { + "axum_app::handlers::search" +} +pub async fn lit() -> &'static str { + "axum_app::handlers::lit" +} +pub async fn trailing() -> &'static str { + "axum_app::handlers::trailing" +} +pub async fn api_fallback() -> (axum::http::StatusCode, &'static str) { + ( + axum::http::StatusCode::NOT_FOUND, + "axum_app::handlers::api_fallback", + ) +} +pub async fn admin_stats() -> &'static str { + "axum_app::handlers::admin_stats" +} +pub async fn admin_reset() -> &'static str { + "axum_app::handlers::admin_reset" +} +pub async fn orphan() -> &'static str { + "axum_app::handlers::orphan" +} + +/// 항목 CRUD. +pub mod items { + pub async fn list() -> &'static str { + "axum_app::handlers::items::list" + } + pub async fn create() -> &'static str { + "axum_app::handlers::items::create" + } + pub async fn show() -> &'static str { + "axum_app::handlers::items::show" + } + pub async fn update() -> &'static str { + "axum_app::handlers::items::update" + } + pub async fn remove() -> &'static str { + "axum_app::handlers::items::remove" + } + pub async fn tag() -> &'static str { + "axum_app::handlers::items::tag" + } + pub async fn special() -> &'static str { + "axum_app::handlers::items::special" + } +} + +/// 파일 catch-all. +pub mod files { + pub async fn serve() -> &'static str { + "axum_app::handlers::files::serve" + } +} + +/// 모든 method를 받는 사용자 경로. +pub mod users { + pub async fn any_method() -> &'static str { + "axum_app::handlers::users::any_method" + } +} diff --git a/tests/fixture-routes/axum08/axum_app/src/lib.rs b/tests/fixture-routes/axum08/axum_app/src/lib.rs new file mode 100644 index 0000000..70608e4 --- /dev/null +++ b/tests/fixture-routes/axum08/axum_app/src/lib.rs @@ -0,0 +1,17 @@ +//! 합성 axum 0.8 서버 — `rustograph routes` 테스트와 라우팅 오라클이 같은 소스를 쓴다. +//! 핸들러는 자기 정점 ID를 응답 본문으로 돌려준다(오라클이 어느 핸들러에 닿았는지 본다). + +pub mod handlers; +pub mod routes; + +use axum::routing::get; +use axum::Router; + +/// 서빙되는 라우터(main.rs가 `axum::serve`로 띄운다). +pub fn app() -> Router { + Router::new() + .route("/", get(handlers::root)) + .route("/health", get(handlers::health).head(handlers::health_head)) + .nest("/api", routes::api()) + .merge(routes::admin()) +} diff --git a/tests/fixture-routes/axum08/axum_app/src/main.rs b/tests/fixture-routes/axum08/axum_app/src/main.rs new file mode 100644 index 0000000..f26d086 --- /dev/null +++ b/tests/fixture-routes/axum08/axum_app/src/main.rs @@ -0,0 +1,7 @@ +//! 서버 진입점 — 여기서 서빙하는 라우터가 선언의 루트다. + +#[tokio::main] +async fn main() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); + axum::serve(listener, axum_app::app()).await.unwrap(); +} diff --git a/tests/fixture-routes/axum08/axum_app/src/routes.rs b/tests/fixture-routes/axum08/axum_app/src/routes.rs new file mode 100644 index 0000000..b2eecb4 --- /dev/null +++ b/tests/fixture-routes/axum08/axum_app/src/routes.rs @@ -0,0 +1,47 @@ +//! 하위 라우터들 — nest·merge·지역 변수·재대입·상수 경로. + +use crate::handlers::{self, files, items, users}; +use axum::routing::{any, get, on, post, MethodFilter}; +use axum::Router; + +/// 관리 경로 상수 — 경로 인자가 리터럴이 아니어도 상수면 템플릿이다. +const ADMIN_RESET: &str = "/admin/reset"; + +/// `/api` 아래에 붙는 라우터. +pub fn api() -> Router { + let items = Router::new() + .route("/", get(items::list).post(items::create)) + .route( + "/{id}", + get(items::show).put(items::update).delete(items::remove), + ) + .route("/{id}/tags/{tag}", get(items::tag)) + .route("/special", get(items::special)); + let mut router = Router::new().nest("/items", items); + router = router.route("/files/{*path}", get(files::serve)); + router + .route("/v{version}/status", get(handlers::status)) + .route("/v/status", post(handlers::status_literal)) + .route("/users/{id}", any(users::any_method)) + .route( + "/search", + on(MethodFilter::GET.or(MethodFilter::POST), handlers::search), + ) + .route("/closure", get(|| async { "axum_app::routes::api" })) + .route("/lit/{{braces}}", get(handlers::lit)) + .route("/trailing/", get(handlers::trailing)) + .route("/tag_{name}", get(handlers::tag_prefixed)) + .fallback(handlers::api_fallback) +} + +/// 루트에 merge되는 관리 라우터. +pub fn admin() -> Router { + Router::new() + .route("/admin/stats", get(handlers::admin_stats)) + .route(ADMIN_RESET, post(handlers::admin_reset)) +} + +/// 어디에도 붙지 않는 라우터 — 서빙 루트에서 닿지 않으므로 base 앵커로 나온다. +pub fn orphan() -> Router { + Router::new().route("/orphan", get(handlers::orphan)) +} diff --git a/tests/fixture-routes/stubs/actix-web/Cargo.toml b/tests/fixture-routes/stubs/actix-web/Cargo.toml new file mode 100644 index 0000000..024b5b9 --- /dev/null +++ b/tests/fixture-routes/stubs/actix-web/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "actix-web" +version = "4.15.0" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# 경로 문법을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 라우팅 동작은 experiments/routes-oracle이 crates.io의 진짜 크레이트로 확인한다. diff --git a/tests/fixture-routes/stubs/actix-web/src/lib.rs b/tests/fixture-routes/stubs/actix-web/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-routes/stubs/actix-web/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/fixture-routes/stubs/axum-0.7/Cargo.toml b/tests/fixture-routes/stubs/axum-0.7/Cargo.toml new file mode 100644 index 0000000..32060c4 --- /dev/null +++ b/tests/fixture-routes/stubs/axum-0.7/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "axum" +version = "0.7.9" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# 경로 문법을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 라우팅 동작은 experiments/routes-oracle이 crates.io의 진짜 크레이트로 확인한다. diff --git a/tests/fixture-routes/stubs/axum-0.7/src/lib.rs b/tests/fixture-routes/stubs/axum-0.7/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-routes/stubs/axum-0.7/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/fixture-routes/stubs/axum-0.8/Cargo.toml b/tests/fixture-routes/stubs/axum-0.8/Cargo.toml new file mode 100644 index 0000000..eebc6dc --- /dev/null +++ b/tests/fixture-routes/stubs/axum-0.8/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "axum" +version = "0.8.9" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# 경로 문법을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 라우팅 동작은 experiments/routes-oracle이 crates.io의 진짜 크레이트로 확인한다. diff --git a/tests/fixture-routes/stubs/axum-0.8/src/lib.rs b/tests/fixture-routes/stubs/axum-0.8/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-routes/stubs/axum-0.8/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/integration.rs b/tests/integration.rs index c26edac..224d949 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -649,3 +649,88 @@ fn inherent_impl_methods_and_scoped_self_call() { && e.kind == EdgeKind::Call && e.tentative)); } + +/// 표현식의 단일 식별자는 지역 묶음이 아이템보다 먼저다(rustc 값 이름공간). +/// 같은 이름의 크레이트 루트 모듈·함수로 읽으면 가짜 references 간선이 +/// 생기고, 순회가 그 모듈이 import한 아이템 전부로 번진다. +#[test] +fn locals_shadow_items_and_modules_are_never_values() { + let tmp = std::env::temp_dir().join(format!("rg-locals-{}", std::process::id())); + std::fs::create_dir_all(tmp.join("src")).unwrap(); + std::fs::write( + tmp.join("Cargo.toml"), + "[package]\nname = \"locals\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write( + tmp.join("src/lib.rs"), + r#" +pub mod repo { pub struct Repo; impl Repo { pub fn all(&self) -> u32 { 1 } } } +pub fn helper() -> u32 { 2 } +pub fn item() -> u32 { 3 } +pub const MAX: u32 = 9; +pub struct S; +impl S { pub fn me(&self) -> u32 { self.me2() } pub fn me2(&self) -> u32 { 0 } } + +pub fn param(repo: &repo::Repo) -> u32 { repo.all() } +pub fn let_shadow() -> u32 { let helper = helper(); helper + 1 } +pub fn closure() -> u32 { let f = |item: u32| item + 1; f(1) } +pub fn arms(x: Option) -> u32 { + match x { Some(item) => item, None => MAX } +} +pub fn if_let(x: Option) -> u32 { + if let Some(helper) = x { helper } else { helper() } +} +pub fn loops(v: Vec) -> u32 { let mut n = 0; for item in v { n += item; } n } +pub fn block_scope() -> u32 { { let item = 1; let _ = item; } item() } +pub fn capture(repo: u32) -> String { format!("{repo}") } +pub mod util { pub fn inner() {} } +pub fn util() -> u32 { 4 } +pub fn same_name() -> u32 { util() } +pub fn nested_fn() { let _ = { fn go(helper: u32) -> u32 { helper } go(1) }; } +"#, + ) + .unwrap(); + let d = source::load( + &tmp, + &source::Options { + symbol_level: true, + ..Default::default() + }, + ) + .expect("temp crate harvest failed"); + let _ = std::fs::remove_dir_all(&tmp); + let has = |from: &str, to: &str| d.edges.iter().any(|e| e.from == from && e.to == to); + // 매개변수·let·클로저·match 팔·if let·for 묶음은 아이템을 가린다. + assert!( + !has("locals::param", "locals::repo"), + "param shadowed module" + ); + assert!(!has("locals::closure", "locals::item")); + assert!(!has("locals::arms", "locals::item")); + assert!(!has("locals::loops", "locals::item")); + assert!(!has("locals::capture", "locals::repo"), "format capture"); + // 초기식은 새 묶음을 못 본다 — `let helper = helper()`는 fn 호출이다. + assert!(has("locals::let_shadow", "locals::helper")); + // if let 묶음은 else에서 안 보인다. + assert!(has("locals::if_let", "locals::helper")); + // 대문자 식별자 패턴은 묶음이 아니다(상수 비교) — 참조는 남는다. + assert!(has("locals::arms", "locals::MAX")); + // 블록을 나가면 지역이 사라진다. + assert!(has("locals::block_scope", "locals::item")); + // 모듈과 같은 이름의 fn(다른 이름공간)은 값 자리에서 fn으로 남는다. + assert!( + has("locals::same_name", "locals::util"), + "fn sharing a module name" + ); + // 블록 안 fn의 매개변수도 지역이다. + assert!( + !has("locals::nested_fn", "locals::helper"), + "nested fn param" + ); + // `self` 수신자는 모듈이 아니다. + assert!(!d + .edges + .iter() + .any(|e| e.from == "locals::S::me" && e.to == "locals")); +} diff --git a/tests/routes.rs b/tests/routes.rs new file mode 100644 index 0000000..14979ab --- /dev/null +++ b/tests/routes.rs @@ -0,0 +1,798 @@ +//! `rustograph routes --role server` — isthmus 공유 벡터, 오라클 기록 대조, 추출 규칙. + +use rustograph::source::routes::{self, template, validate, Framework, RouteOptions}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +fn repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// fixture 하나의 routes 문서(JSON). +fn doc_of(dir: &Path, framework: Option) -> Value { + let d = routes::routes(dir, "test", &RouteOptions { framework }).expect("routes extraction"); + serde_json::to_value(&d).expect("serializes") +} + +fn load(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("readable")).expect("json") +} + +// ── 공유 벡터 ────────────────────────────────────────────── + +/// 실행하는 생산자 ruleId. +const APPLICABLE: &[&str] = &[ + "template.grammar", + "template.normalize", + "dispatch.validate", + "scope.validate", +]; + +/// 건너뛰는 ruleId 접두사 — 새 ruleId가 오면 테스트가 판단을 요구한다. +const SKIPPED: &[&str] = &[ + "match.", + "dispatch.match", + "dispatch.shadow", + "scope.applies", + "framework.openapi.", + "framework.spring.", + "compose.", + "wrapper.", +]; + +fn cases() -> Vec { + let mut out = Vec::new(); + let mut names: Vec = std::fs::read_dir(repo().join("conformance")) + .expect("conformance dir") + .map(|e| e.expect("entry").path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect(); + names.sort(); + for p in names { + let suite = load(&p); + out.extend(suite["cases"].as_array().expect("cases").iter().cloned()); + } + out +} + +fn applicable() -> Vec { + cases() + .into_iter() + .filter(|c| { + APPLICABLE.contains(&c["ruleId"].as_str().unwrap_or("")) + && c["appliesTo"] + .as_array() + .is_some_and(|a| a.iter().any(|t| t == "producer")) + }) + .collect() +} + +#[test] +fn vendored_vectors_match_sums_and_lock() { + let dir = repo().join("conformance"); + let sums: std::collections::BTreeMap = + std::fs::read_to_string(dir.join("SHA256SUMS")) + .expect("sums") + .lines() + .filter(|l| !l.is_empty()) + .map(|l| { + let (h, n) = l.split_once(" ").expect("sha256sum format"); + (n.to_string(), h.to_string()) + }) + .collect(); + let lock = load(&repo().join("conformance.lock")); + let locked: BTreeSet = lock["files"] + .as_object() + .expect("files") + .keys() + .cloned() + .collect(); + let mut vectors: Vec = std::fs::read_dir(&dir) + .expect("dir") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".json")) + .collect(); + vectors.sort(); + assert_eq!(sums.keys().cloned().collect::>(), vectors); + assert_eq!(locked.into_iter().collect::>(), vectors); + assert_eq!(lock["commit"].as_str().map(str::len), Some(40)); + for n in &vectors { + let bytes = std::fs::read(dir.join(n)).expect("vector"); + let digest = rustograph::traversal::sha256::hex_digest(&bytes); + assert_eq!(digest, sums[n], "{n}: re-vendor it from isthmus"); + assert_eq!( + Some(digest.as_str()), + lock["files"][n].as_str(), + "{n}: lock" + ); + } +} + +#[test] +fn every_case_is_applicable_or_classified() { + let unknown: Vec = cases() + .iter() + .filter(|c| { + let rule = c["ruleId"].as_str().unwrap_or(""); + !APPLICABLE.contains(&rule) && !SKIPPED.iter().any(|p| rule.starts_with(p)) + }) + .map(|c| c["id"].to_string()) + .collect(); + assert!(unknown.is_empty(), "classify new vector cases: {unknown:?}"); +} + +#[test] +fn producer_cases_pass() { + let cases = applicable(); + // 조용히 줄지 않게 — 벡터 재벤더링 때 수를 확인한다. + assert_eq!(cases.len(), 26 + 7 + 18 + 9); + for c in &cases { + let id = c["id"].as_str().unwrap_or(""); + let input = &c["input"]; + let expect = &c["expect"]; + match c["ruleId"].as_str().unwrap_or("") { + "template.grammar" => { + let problem = template::template_problem(input["template"].as_str().unwrap()); + assert_eq!(problem.is_none(), expect["valid"] == true, "{id}"); + if let Some(reason) = expect["reason"].as_str() { + assert_eq!(problem, Some(reason), "{id}"); + } + } + "template.normalize" => assert_eq!( + template::normalize_uri_path(input["path"].as_str().unwrap()), + expect["template"].as_str().unwrap(), + "{id}" + ), + "dispatch.validate" => { + // isthmus 참조 실행기와 같게 빠진 위치·kind를 채운다. + let mut doc = input["document"].clone(); + let facts = doc["facts"].as_array_mut().unwrap(); + for (i, f) in facts.iter_mut().enumerate() { + let line = f.pointer("/location/line").cloned().unwrap_or(json!(i + 1)); + let column = f.pointer("/location/column").cloned().unwrap_or(json!(1)); + f["location"] = json!({"path": "shop/urls.py", "line": line, "column": column}); + } + let valid = validate::order_problem(&doc).is_none(); + assert_eq!(valid, expect["valid"] == true, "{id}"); + } + "scope.validate" => { + let mut scope = input["scope"].clone(); + scope["limitationIndex"] = json!(0); + assert_eq!( + validate::scope_problem(&scope).is_none(), + expect["valid"] == true, + "{id}" + ); + } + other => panic!("unexpected rule {other}"), + } + } +} + +// ── 오라클 기록 대조 ──────────────────────────────────────── + +/// 오라클 기록과 같은 모양의 사실 키. +fn fact_key(f: &Value) -> Value { + let mut k = json!({ + "method": f["method"], + "channel": f["channel"], + "pathAnchor": f["pathAnchor"], + }); + if let Some(t) = f.get("trailingSlash") { + k["trailingSlash"] = t.clone(); + } + if let Some(o) = f.get("order") { + k["order"] = json!([o["group"], o["index"]]); + } + if let Some(u) = f.pointer("/symbol/usr") { + k["usr"] = u.clone(); + } + if f.get("narrowed").is_some() { + k["narrowed"] = json!(true); + } + if let Some(pc) = f["paramConstraints"].as_array() { + k["paramConstraints"] = pc + .iter() + .map(|c| { + json!([ + c["segment"], + c["kind"], + c.get("pattern").cloned().unwrap_or(Value::Null) + ]) + }) + .collect(); + } + k +} + +/// 지금 출력의 정적 사실이 오라클이 실제 요청으로 확인한 사실(과 요청할 수 없는 +/// base 사실)과 정확히 같아야 한다. 다시 기록하려면 experiments/routes-oracle/run_all.sh. +fn check_fixture(name: &str) { + let doc = doc_of(&repo().join("tests/fixture-routes").join(name), None); + assert!(validate::order_problem(&doc).is_none()); + let now: BTreeSet = doc["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["dynamic"] == false) + .map(|f| fact_key(f).to_string()) + .collect(); + let rec = load(&repo().join(format!("experiments/routes-oracle/recorded/{name}.json"))); + for t in ["precision", "recall", "negatives", "trailingSlash"] { + assert_eq!(rec[t]["passed"], rec[t]["total"], "{name} {t} is not 100%"); + assert!( + rec[t]["total"].as_u64().unwrap() > 0, + "{name} {t} probed nothing" + ); + } + assert!(rec["failedFacts"].as_array().unwrap().is_empty()); + let mut recorded: BTreeSet = rec["verifiedFacts"] + .as_array() + .unwrap() + .iter() + .map(|f| f.to_string()) + .collect(); + recorded.extend( + rec["unprobed"] + .as_array() + .unwrap() + .iter() + .map(|u| u[0].to_string()), + ); + // 키 순서를 정규화한다(serde_json Map은 정렬된 맵이라 다시 쓰면 같은 바이트). + let normalize = |s: &BTreeSet| -> BTreeSet { + s.iter() + .map(|x| serde_json::from_str::(x).unwrap().to_string()) + .collect() + }; + assert_eq!( + normalize(&now), + normalize(&recorded), + "{name}: re-run the oracle" + ); +} + +#[test] +fn axum08_matches_oracle() { + check_fixture("axum08"); +} + +#[test] +fn axum07_matches_oracle() { + check_fixture("axum07"); +} + +#[test] +fn actix_matches_oracle() { + check_fixture("actix"); +} + +// ── 추출 규칙(임시 크레이트) ──────────────────────────────── + +/// 임시 워크스페이스 — 스텁 의존성의 이름·버전과 소스 파일들. +fn temp_crate(tag: &str, deps: &[(&str, &str)], files: &[(&str, &str)]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("rg-routes-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("app/src")).unwrap(); + let mut dep_lines = String::new(); + for (name, version) in deps { + let stub = dir.join(format!("stub-{name}")); + std::fs::create_dir_all(stub.join("src")).unwrap(); + std::fs::write( + stub.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"), + ) + .unwrap(); + std::fs::write(stub.join("src/lib.rs"), "").unwrap(); + dep_lines.push_str(&format!("{name} = {{ path = \"../stub-{name}\" }}\n")); + } + std::fs::write( + dir.join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("app/Cargo.toml"), + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{dep_lines}"), + ) + .unwrap(); + for (path, text) in files { + std::fs::write(dir.join("app/src").join(path), text).unwrap(); + } + dir +} + +fn facts(doc: &Value) -> Vec<(String, String, Option)> { + doc["facts"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["method"].as_str().unwrap().to_string(), + f["channel"].as_str().unwrap_or("").to_string(), + f["trailingSlash"].as_str().map(str::to_string), + ) + }) + .collect() +} + +fn limitations(doc: &Value) -> Vec { + doc["limitations"] + .as_array() + .unwrap() + .iter() + .map(|l| l.as_str().unwrap().to_string()) + .collect() +} + +/// NormalizePathLayer는 Router 바깥을 감쌀 때만 라우팅 전에 동작한다 — Router::layer +/// 인자로만 쓰면 끝 슬래시는 엄격하다(axum layer.md:57-62). +#[test] +fn axum_normalize_path_only_counts_outside_the_router() { + let src_outer = r#" +use axum::{routing::get, Router, ServiceExt}; +use tower_http::normalize_path::NormalizePathLayer; +use tower::Layer; +async fn h() {} +pub fn app() -> Router { Router::new().route("/items", get(h)).route("/dir/", get(h)) } +pub async fn run() { + let svc = NormalizePathLayer::trim_trailing_slash().layer(app()); + axum::serve(listener(), ServiceExt::::into_make_service(svc)).await; + axum::serve(listener(), app()).await; +} +"#; + let dir = temp_crate("norm", &[("axum", "0.8.9")], &[("lib.rs", src_outer)]); + let d = doc_of(&dir, None); + let f = facts(&d); + assert!( + f.contains(&("GET".into(), "/items".into(), Some("optional".into()))), + "{f:?}" + ); + assert!( + f.contains(&("GET".into(), "/dir/".into(), Some("strict".into()))), + "{f:?}" + ); + let src_inner = r#" +use axum::{routing::get, Router}; +use tower_http::normalize_path::NormalizePathLayer; +async fn h() {} +pub fn app() -> Router { + Router::new().route("/items", get(h)).layer(NormalizePathLayer::trim_trailing_slash()) +} +pub async fn run() { axum::serve(listener(), app()).await; } +"#; + std::fs::write(dir.join("app/src/lib.rs"), src_inner).unwrap(); + let d = doc_of(&dir, None); + assert!(facts(&d).contains(&("GET".into(), "/items".into(), Some("strict".into())))); + // MethodRouter::layer도 라우팅 뒤다. 변수로 넘긴 레이어는 모양을 못 읽어 효과 없음. + let src_method = r#" +use axum::{routing::get, Router}; +use tower_http::normalize_path::NormalizePathLayer; +async fn h() {} +pub fn app() -> Router { + let norm = NormalizePathLayer::trim_trailing_slash(); + Router::new() + .route("/items", get(h).layer(NormalizePathLayer::trim_trailing_slash())) + .route("/other", get(h)) + .layer(norm) +} +pub async fn run() { axum::serve(listener(), app()).await; } +"#; + std::fs::write(dir.join("app/src/lib.rs"), src_method).unwrap(); + let d = doc_of(&dir, None); + let f = facts(&d); + assert!( + f.contains(&("GET".into(), "/items".into(), Some("strict".into()))), + "{f:?}" + ); + assert!( + f.contains(&("GET".into(), "/other".into(), Some("strict".into()))), + "{f:?}" + ); + // ServiceBuilder로 감싼 서비스는 라우팅 전이다. + let src_builder = r#" +use axum::{routing::get, Router}; +use tower_http::normalize_path::NormalizePathLayer; +async fn h() {} +pub fn app() -> Router { Router::new().route("/items", get(h)) } +pub async fn run() { + let svc = tower::ServiceBuilder::new().layer(NormalizePathLayer::trim_trailing_slash()).service(app()); + serve_somehow(svc); + axum::serve(listener(), app()).await; +} +"#; + std::fs::write(dir.join("app/src/lib.rs"), src_builder).unwrap(); + let d = doc_of(&dir, None); + assert!(facts(&d).contains(&("GET".into(), "/items".into(), Some("optional".into())))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// 평가하지 못한 nest·동적 경로는 한계와 dynamic 사실로 남는다(조용히 버리지 않는다). +#[test] +fn axum_unknown_parts_are_limitations() { + let src = r#" +use axum::{routing::get, Router}; +async fn h() {} +pub fn app(prefix: &str) -> Router { + Router::new() + .route(prefix, get(h)) + .nest("/ext", external::router()) + .route("/ok", get(h)) +} +pub async fn run() { axum::serve(listener(), app("/x")).await; } +"#; + let dir = temp_crate("unknown", &[("axum", "0.8.9")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + let dynamic = d["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["dynamic"] == true) + .count(); + assert_eq!(dynamic, 1); + let lims = limitations(&d); + assert!( + lims.iter() + .any(|l| l.starts_with("route-coverage: a nested router")), + "{lims:?}" + ); + let scope = d["limitationScopes"].as_array().unwrap(); + assert!( + scope + .iter() + .any(|s| s["templatePrefixes"] == json!(["/ext"])), + "{scope:?}" + ); +} + +/// 두 프레임워크가 모두 있으면 추측하지 않고 사용법 오류, 없으면 사실 0건과 한계. +#[test] +fn framework_choice_is_explicit() { + let dir = temp_crate( + "both", + &[("axum", "0.8.9"), ("actix-web", "4.15.0")], + &[("lib.rs", "")], + ); + let err = routes::routes(&dir, "test", &RouteOptions::default()) + .err() + .expect("usage error"); + assert!(err.contains("--framework"), "{err}"); + let d = doc_of(&dir, Some(Framework::Actix)); + assert_eq!(d["dispatch"], "registration-order"); + let _ = std::fs::remove_dir_all(&dir); + let none = temp_crate("none", &[], &[("lib.rs", "pub fn f() {}")]); + let d = doc_of(&none, None); + let _ = std::fs::remove_dir_all(&none); + assert_eq!(d["target"], "http"); + assert!(d["facts"].as_array().unwrap().is_empty()); + assert!(limitations(&d)[0].starts_with("route-coverage: no workspace member")); +} + +/// actix 설정 클로저·설정 함수 위임·알 수 없는 서비스·App 기본 서비스. +#[test] +fn actix_configure_and_unknown_services() { + let src = r#" +use actix_web::{web, App}; +async fn h() -> &'static str { "" } +fn nested(cfg: &mut web::ServiceConfig) { cfg.route("/deep", web::put().to(h)); } +fn outer(cfg: &mut web::ServiceConfig) { nested(cfg); } +pub fn build() { + let _ = App::new() + .configure(|cfg| { cfg.service(web::resource("/c").to(h)); }) + .service(web::scope("/s").configure(outer).service(other::thing())) + .default_service(web::to(h)); +} +"#; + let dir = temp_crate("actixcfg", &[("actix-web", "4.15.0")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + let f = facts(&d); + assert!( + f.contains(&("ANY".into(), "/c".into(), Some("strict".into()))), + "{f:?}" + ); + assert!( + f.contains(&("PUT".into(), "/s/deep".into(), Some("strict".into()))), + "{f:?}" + ); + let orders: Vec = d["facts"] + .as_array() + .unwrap() + .iter() + .map(|x| x["order"]["index"].as_u64().unwrap()) + .collect(); + assert_eq!(orders.len(), 2); + let lims = limitations(&d); + assert!( + lims.iter().any(|l| l.contains("App default service")), + "{lims:?}" + ); + assert!( + lims.iter().any(|l| l.contains("could not evaluate")), + "{lims:?}" + ); + let scope = d["limitationScopes"].as_array().unwrap(); + assert!( + scope.iter().any(|s| s["templatePrefixes"] == json!(["/s"])), + "{scope:?}" + ); +} + +/// 사실의 (method, channel, trailingSlash, narrowed, order index, usr) 요약. +fn rows(doc: &Value) -> Vec { + doc["facts"] + .as_array() + .unwrap() + .iter() + .map(|f| { + format!( + "{} {} {} {}{}{}", + f["method"].as_str().unwrap(), + f["channel"].as_str().unwrap_or("null"), + f["pathAnchor"].as_str().unwrap(), + f["trailingSlash"].as_str().unwrap_or("-"), + if f.get("narrowed").is_some() { + " narrowed" + } else { + "" + }, + f.pointer("/symbol/usr") + .and_then(Value::as_str) + .map(|u| format!(" {u}")) + .unwrap_or_default() + ) + }) + .collect() +} + +/// actix 가드·method 생성자·리소스 메서드·기본 서비스·리다이렉트·파일 서비스· +/// 정규화 모드·동적 스코프·매크로 옵션. +#[test] +fn actix_guards_services_and_normalize_modes() { + let src = r#" +use actix_web::{get, guard, route, web, App, http::Method}; +use actix_web::middleware::{NormalizePath, TrailingSlash}; +async fn h() -> &'static str { "" } +#[route("/prop", method = "PROPFIND")] +async fn custom() -> &'static str { "" } +#[get("/g", guard = "is_admin")] +async fn guarded() -> &'static str { "" } +fn scope_of() -> actix_web::Scope { web::scope("/fn").route("/x", web::get().to(h)) } +pub struct Server; +impl Server { pub fn app() { let _ = App::new(); } } +pub fn build(dynamic: &str) { + let _ = App::new() + .wrap(NormalizePath::new(TrailingSlash::Always)) + .service(web::resource("/any-or").guard(guard::Any(guard::Get()).or(guard::Post())).to(h)) + .service(web::resource("/m").guard(guard::Method(Method::PUT)).to(h)) + .service(web::resource("/hdr").guard(guard::Header("x", "y")).to(h)) + .service(web::resource("/both").guard(guard::All(guard::Get()).and(guard::Header("a", "b"))).to(h)) + .route("/patch", web::method(Method::PATCH).to(h)) + .route("/routed", web::route().method(Method::DELETE).to(h)) + .service(web::resource("/res").get(h).post(h).default_service(web::to(h))) + .service(web::redirect("/old", "/new")) + .service(actix_files::Files::new("/static", "./static")) + .service(web::resource("/tail/{rest:.*}").to(h)) + .service(web::resource("/two/{a}.{b}").to(h)) + .service((guarded, custom)) + .service(web::scope(dynamic).route("/under", web::get().to(h))) + .service(web::scope("/merge").wrap(NormalizePath::new(TrailingSlash::MergeOnly)).route("/k", web::get().to(h)).default_service(web::to(h))) + .service(scope_of()); + let _ = App::new().service(web::resource("/second").to(h)); +} +"#; + let dir = temp_crate("actixmix", &[("actix-web", "4.15.0")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + let r = rows(&d); + let has = |s: &str| r.iter().any(|x| x == s); + for want in [ + "GET /any-or root strict app::h", + "POST /any-or root strict app::h", + "PUT /m root strict app::h", + "ANY /hdr root strict narrowed app::h", + "GET /both root strict narrowed app::h", + "PATCH /patch root strict app::h", + "DELETE /routed root strict app::h", + "GET /res root strict app::h", + "POST /res root strict app::h", + "ANY /res root strict app::h", + "ANY /old root strict", + "ANY /tail/{**} root - app::h", + "ANY /tail/ root optional app::h", + "GET /g root strict narrowed app::guarded", + "GET /under base strict app::h", + "GET /merge/k root strict app::h", + "GET /fn/x root strict app::h", + "ANY /second root strict app::h", + ] { + assert!(has(want), "missing {want:?} in {r:#?}"); + } + let dynamic = d["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["dynamic"] == true) + .count(); + assert_eq!(dynamic, 1, "two params in one segment is dynamic"); + let groups: BTreeSet<&str> = d["facts"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f.pointer("/order/group").and_then(Value::as_str)) + .collect(); + assert_eq!( + groups.len(), + 2, + "each App is its own order group: {groups:?}" + ); + let lims = limitations(&d); + for want in [ + "route-coverage: route macro on app::custom uses method PROPFIND", + "framework-provided-routes: an actix-files service", + "route-coverage: route pattern \"/two/{a}.{b}\"", + "route-coverage: a scope default service", + "unresolved-route-prefix:", + "route-dispatch-order-unknown:", + "route-coverage: an App built inside a method", + ] { + assert!( + lims.iter().any(|l| l.starts_with(want)), + "missing {want:?} in {lims:#?}" + ); + } + let scopes = d["limitationScopes"].as_array().unwrap(); + assert!(scopes + .iter() + .any(|s| s["templatePrefixes"] == json!(["/static"]) + && s["methods"] == json!(["GET", "HEAD"]))); +} + +/// axum 메서드 라우터 변형·서비스·CONNECT·연관 함수 핸들러·동적 nest 접두사. +#[test] +fn axum_method_router_variants() { + let src = r#" +use axum::routing::{any_service, connect, get, get_service, on, MethodFilter, MethodRouter}; +use axum::Router; +async fn h() {} +pub struct Api; +impl Api { pub async fn list() {} pub fn routes() -> Router { Router::new() } } +fn methods() -> MethodRouter { MethodRouter::new().get(h).merge(axum::routing::post(h)) } +pub fn app(p: &str) -> Router { + Router::new() + .route("/assoc", get(Api::list)) + .route("/unknown-filter", on(some_filter(), h)) + .route("/svc", get_service(svc()).fallback_service(svc())) + .route("/anysvc", any_service(svc())) + .route("/conn", connect(h)) + .route("/merged", methods()) + .route("/mf", get(h).on(MethodFilter::DELETE, h).fallback(h)) + .nest(p, Router::new().route("/tail", get(h))) + .route("/bad/{a}{b}", get(h)) + .route("/pre{*rest}", get(h)) +} +pub async fn run() { axum::serve(listener(), app("/x")).await; } +"#; + let dir = temp_crate("axmix", &[("axum", "0.8.9")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + let r = rows(&d); + let has = |s: &str| r.iter().any(|x| x == s); + for want in [ + "GET /assoc root strict app::Api::list", + "ANY /unknown-filter root strict app::h", + "GET /svc root strict", + "ANY /svc root strict", + "ANY /anysvc root strict", + "GET /merged root strict app::h", + "POST /merged root strict app::h", + "DELETE /mf root strict app::h", + "ANY /mf root strict app::h", + "GET /tail base strict app::h", + ] { + assert!(has(want), "missing {want:?} in {r:#?}"); + } + let lims = limitations(&d); + for want in [ + "route-coverage: a CONNECT route", + "route-coverage: route path \"/bad/{a}{b}\"", + "route-coverage: route path \"/pre{*rest}\"", + "unresolved-route-prefix: 1 route declaration(s) sit under a nest prefix", + "route-coverage: a Router built inside a method", + "missing-route-usrs:", + ] { + assert!( + lims.iter().any(|l| l.starts_with(want)), + "missing {want:?} in {lims:#?}" + ); + } + let dynamic = d["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["dynamic"] == true) + .count(); + assert_eq!( + dynamic, 1, + "catch-all after static text is a dynamic declaration" + ); +} + +/// 버전 밖 axum은 가까운 문법으로 읽고 한계를 낸다. +#[test] +fn axum_unverified_version_is_a_limitation() { + let src = r#" +use axum::{routing::get, Router}; +async fn h() {} +pub fn app() -> Router { Router::new().route("/items/:id", get(h)) } +pub async fn run() { axum::serve(listener(), app()).await; } +"#; + let dir = temp_crate("ax06", &[("axum", "0.6.20")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + assert!(rows(&d).contains(&"GET /items/{} root strict app::h".to_string())); + assert!(limitations(&d)[0].starts_with("route-framework-version-unknown: axum 0.6.20")); +} + +/// 루트에서 닿지 않은 라우터끼리의 nest — 안쪽 라우터가 따로 한 번 더 나오지 않는다 +/// (이름 순서와 무관). +#[test] +fn unrooted_inner_router_is_not_emitted_twice() { + let src = r#" +use axum::{routing::get, Router}; +async fn h() {} +pub fn a_inner() -> Router { Router::new().route("/leaf", get(h)) } +pub fn b_outer() -> Router { Router::new().nest("/mid", a_inner()) } +"#; + let dir = temp_crate("unrooted", &[("axum", "0.8.9")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + rows(&d), + vec!["GET /mid/leaf base strict app::h".to_string()] + ); +} + +/// actix App을 지역 변수로 키우는 재대입은 따라가고, 함수에 넘긴 App은 한계로 센다. +#[test] +fn actix_app_through_variables_and_helpers() { + let src = r#" +use actix_web::{web, App}; +async fn h() -> &'static str { "" } +fn extend(app: T) -> T { app } +pub fn build() { + let mut app = App::new().route("/a", web::get().to(h)); + app = app.route("/b", web::post().to(h)); + let _ = extend(App::new().route("/c", web::get().to(h))); + let _ = app; +} +"#; + let dir = temp_crate("actixvar", &[("actix-web", "4.15.0")], &[("lib.rs", src)]); + let d = doc_of(&dir, None); + let _ = std::fs::remove_dir_all(&dir); + let r = rows(&d); + for want in [ + "GET /a root strict app::h", + "POST /b root strict app::h", + "GET /c root strict app::h", + ] { + assert!(r.iter().any(|x| x == want), "missing {want:?} in {r:#?}"); + } + // /a·/b는 한 App(같은 group), /c는 다른 App이다. + let group_of = |ch: &str| { + d["facts"] + .as_array() + .unwrap() + .iter() + .find(|f| f["channel"] == ch) + .and_then(|f| f.pointer("/order/group").cloned()) + }; + assert_eq!(group_of("/a"), group_of("/b")); + assert_ne!(group_of("/a"), group_of("/c")); + assert!(limitations(&d) + .iter() + .any(|l| l.starts_with("route-coverage: an App passed to a function"))); +}