diff --git a/.gitignore b/.gitignore index 26bc0b4..5701b75 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ .serena/ shrimp/ experiments/routes-oracle/target/ +experiments/client-oracle/target/ diff --git a/HANDOFF.md b/HANDOFF.md index fdf33b6..fbc45cd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,7 +2,42 @@ 세션을 이어받는 에이전트가 먼저 읽는 문서입니다. -## 진행 중 — feature/server-routes (2026-09-30, API 영향 프로그램 Phase 7 후속) +## 진행 중 — feature/client-routes (2026-09-30, API 영향 프로그램 개선 #3 Rust 부분) + +`rustograph routes --role client`(isthmus http `route-call` 생산자). 규칙·근거·오라클 표는 `docs/HTTP-CLIENT.md`. + +- **코드** — `src/source/routes/client.rs`(멤버·의존 버전, 문서 조립, 한계) + `client/{index,scan}.rs`(색인과 두 단계 + 스캐너: 구조체 필드 값 수집 → 호출 사실), `compose.rs`(url-compose 규칙의 순수 구현 — `UrlVal`, WHATWG concat/join, + `http::Uri` concat, 마스킹), `wrappers.rs`(http-wrappers v1 파싱·동사 바인딩). 위치 열은 계약대로 UTF-8 바이트다 + (`Ctx::locate_utf8`). **서버 `route-decl`의 `Ctx::locate`는 UTF-16 열을 낸다 — GRAPH-EXCHANGE는 UTF-8 바이트를 + 요구하므로 비ASCII 줄에서 어긋난다(이번 범위 밖, 후속 수정 후보).** +- **인식** — reqwest(`get`·`blocking::get`·Client 동사·`request`·`Request::new`), ureq 2/3 자유 함수·Agent, 선언된 + 래퍼(`owner::name` = 정점 ID, 구조체 리터럴 생성자는 이름 = 타입 이름). 수신자는 구문 타입 추론(생성자·주석·필드 + 타입·반환 타입·static 타입, `Arc`·`LazyLock` 등 벗김). 증명하지 못한 수신자는 사실 없이 센다. +- **검증** — 오라클 41 시나리오: 일치 37 · dynamic 3 · 요청 없음 1 · 불일치 0 + (reqwest 0.13.5·ureq 3.4.2·url 2.5.8, scratch에서 reqwest 0.12.28도 같음). 오라클이 확인한 것: `Url::join`의 마지막 + 세그먼트 교체, WHATWG `//` 보존·점 세그먼트 제거, **ureq 3의 점 세그먼트 보존**, ureq 3이 http 요청도 프록시에 + CONNECT를 연다(기록 서버가 터널을 수락). 커버리지 92.14%, stable clippy(1.98.1) 0. +- **벡터** — isthmus `3a45450`(#133) 재벤더링. url-compose `producer` 41 + `producer:rustograph` 7 = 48/48. 새 규칙에 맞춰 + 미상 base `rfc3986` 상대 참조의 `..`·빈 참조를 dynamic + `ambiguous-base-join:`으로 바꿨다. `scope.dynamic-*`(dynamicScope, + 서버 선언 선택 필드)는 내지 않으므로 분류만 했다. 결합 이름은 isthmus가 `Url::join` = `rfc3986`으로 정했고 reqwest·ureq는 + base가 없어 전체 URL 규칙이다(제안했던 `whatwg-concat` 등은 불필요). +- **e2e(scratch, 무패치 isthmus `3a45450`)** — workspace trace(`server` = axum08 fixture + `reach --roots-from`, + `client` = fixture-client + `impact --format language-traversal --roots-from`, link match hosts `api.example.com`, + selection 3 routes): 3 routes · 3 calls · 3 handlers · 3 client symbols. `POST /api/items` — client + `client_app::api::ApiClient::create_item`(api.rs:38, exact) → handler `axum_app::handlers::items::create` → client 역방향 + `struct_field_plus`(depth 1, candidate — syn 메서드 호출 팬아웃). `GET /api/items/{}`는 `get_item` → `items::show`, + `GET /api/search`는 `search` → `handlers::search`. base 앵커 호출 10건은 host가 없어 `unattributed-calls-omitted`. + isthmus `c395c59`(직전 main)는 같은 client 문서를 "Fact kind is not valid for platform"으로 거부했다. +- **GLM 리뷰** — 재현 후 수정 2건: 상수 사슬 평가가 깊이를 새로 시작해 긴 사슬(3,000개)에서 스택 넘침 → 깊이를 이어 + 모르는 값으로 낮춤(회귀 테스트는 수정 전 코드에서 넘침을 확인), authority가 `_` host·빈 포트를 거부 → WHATWG대로 받음. + 반박 4건: Slack 웹훅 전체 마스킹(벡터 `mask/slack-webhook`이 `/{}/{}/{}/{}`), `--service` 없는 사실 service(계약상 유효 + service는 사실 값), 호출 0건도 `http-wrapper-unresolved:`(HTTP-WRAPPERS 생산자 의무), 중첩 서식 `{:{}}`(Rust 문법 아님). +- 알려진 근사: 필드 수정 감지는 소유 타입을 모르면 이름 단위(보수적), `Url` 값의 `set_path` 등은 지역·필드를 믿지 않게 + 만들 뿐 새 값을 계산하지 않는다, 선언된 구조체 래퍼 값을 받아 다른 래퍼를 부르는 함수(`execute`)는 동사 동적 dynamic + 사실을 하나 더 낸다, ureq 3에서 `http::Uri`가 거부하는 문자(공백 등)가 든 리터럴도 사실로 낸다. + +## 직전 — feature/server-routes (2026-09-30, PR #23 머지됨) `rustograph routes --role server`(isthmus http `route-decl` 생산자)와 수확 이름 해석 결함 수정. diff --git a/README.ko.md b/README.ko.md index 6856a93..11da218 100644 --- a/README.ko.md +++ b/README.ko.md @@ -72,6 +72,7 @@ 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 routes --role client --wrappers http-wrappers.json --out calls.json # route-call(reqwest·ureq) rustograph reach mycrate::api::list_users # isthmus language-traversal(정방향) rustograph impact --format language-traversal --roots-from schema-facts.json # 역방향 ``` @@ -165,6 +166,42 @@ import할 때만 인정합니다. 파싱 실패 파일·문법이 다른 `table! 그 기록을 `cargo test`가 오프라인으로 대조합니다. isthmus 공유 벡터는 `conformance/`에 잠금 파일과 함께 벤더링했습니다. +## 클라이언트 호출 — `routes --role client` + +`rustograph routes --role client`는 코드가 만드는 HTTP 요청마다 `route-call` +사실 하나를 담은 isthmus `bridge-facts` v1 문서(`platform: "rust"`, +`target: "http"`, `roles: ["client"]`)를 냅니다. `symbol.usr`는 호출을 감싼 +함수·메서드이고 `impact`의 정점 ID와 같아서 `isthmus trace`가 호출부에서 그 +호출부에 기대는 클라이언트 코드로 이어 갑니다. + +- **reqwest**(0.13, 0.12도 확인): `reqwest::get`·`blocking::get`, + `Client`·`blocking::Client`의 동사 메서드, `request(Method::X, url)`, + `Request::new`. 문자열은 `url::Url::parse`(WHATWG)로 해석합니다 — 점 + 세그먼트는 지우고 `//`는 남깁니다. +- **ureq** 3(2.x는 소스 기준): 자유 함수와 `Agent` 메서드. ureq 3은 + `http::Uri`로 해석해 점 세그먼트를 남깁니다. +- **URL 조립**: 리터럴, `format!`(위치·이름·인라인 인자), `concat!`, `+`, + 상수·static·연관 상수, 지역 변수(그림자 추적, 수정되는 이름은 믿지 않음), + `Url::parse(..)?.join(..)`(RFC 3986 병합 — `…/v2/catalog` + `tags`는 + `/v2/tags`), 모든 생성자가 같은 리터럴·상수로 채우는 구조체 필드 base. + 그 밖은 dynamic 사실(`channel: null`, 증명한 경우 마스킹한 `channelPrefix`) + 이나 센 한계입니다. +- **래퍼**: isthmus `http-wrappers` v1 파일에 선언한 함수·메서드·구조체 + 리터럴 엔드포인트(`"language": "rust"`, `owner::name`이 rustograph 정점 + ID)는 선언한 동사·앵커로 호출 사실이 됩니다. +- **센 공백**: 모델링하지 않는 클라이언트(hyper client·surf·awc·isahc 등), + 클라이언트로 증명하지 못한 수신자의 요청, 클라이언트가 거부하는 상대 + URL, 선언되지 않은 래퍼 싱크, 풀리지 않는 선언은 `route-call-coverage:`· + `ambiguous-base-join:`·`http-wrapper-undeclared:`·`http-wrapper-unresolved:` + 한계입니다. + +규칙(`Url::join`은 isthmus `rfc3986` 결합, reqwest·ureq는 base URL이 없어 +전체 URL 규칙)과 오라클 표는 [docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md)에 있습니다. 모의 서버 +오라클(`experiments/client-oracle/`)이 fixture를 진짜 reqwest·ureq·url로 +컴파일해 로컬 서버가 받은 요청을 기록합니다 — 41개 시나리오 불일치 0, 기록은 +`cargo test`가 오프라인으로 대조합니다. isthmus `url-compose` 벡터의 +`producer`·`producer:rustograph` 사례 48건을 모두 통과합니다. + ## 순회 문서 — `reach` / `impact --format language-traversal` isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/docs/LANGUAGE-TRAVERSAL.md) @@ -187,9 +224,9 @@ isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/ - 그래프 정점이 아닌 root는 `symbol` 없이 싣고 `root-not-found:` limitation을 더한 문서를 쓴 뒤 64로 끝납니다. -isthmus는 `platform: "rust"` 문서의 `route-decl`을 받지 않아 Rust 핸들러에서 -시작하는 route 선택 `trace`는 아직 불가능합니다. relation·심볼 선택(역방향 -순회)은 지금 동작합니다. +isthmus는 Rust `route-decl`과(isthmus #133부터) `route-call` 문서를 받아, +workspace `trace`가 reqwest 클라이언트를 axum·actix-web 핸들러와 잇고 +`impact`로 클라이언트 코드까지 이어 갑니다. ## 개발 diff --git a/README.md b/README.md index c255415..cb957d2 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,10 @@ rustograph schema --dir . --out schema-facts.json rustograph routes --role server --dir . --out routes.json rustograph reach --roots-from routes.json # handler usrs are graph vertices +# Emit isthmus http route-call facts for reqwest / ureq clients +rustograph routes --role client --dir . --wrappers http-wrappers.json --out calls.json +rustograph impact --format language-traversal --roots-from calls.json + # 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 @@ -266,6 +270,46 @@ 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. +## Client calls — `routes --role client` + +`rustograph routes --role client` emits an isthmus `bridge-facts` v1 +document with `platform: "rust"`, `target: "http"`, `roles: ["client"]` and +one `route-call` fact per HTTP request the code builds. `symbol.usr` is the +enclosing function or method — the same vertex id `impact` uses, so +`isthmus trace` can continue from a call site into the client code that +depends on it. + +- **reqwest** (0.13; 0.12 checked too): `reqwest::get`, `blocking::get`, + `Client`/`blocking::Client` verb methods, `request(Method::X, url)`, + `Request::new`. Strings go through `url::Url::parse` (WHATWG): dot + segments are removed, `//` is kept. +- **ureq** 3 (2.x read from source): free functions and `Agent` methods. + ureq 3 parses with `http::Uri`, which keeps dot segments. +- **URL building**: literals, `format!` (positional, named and inline + arguments), `concat!`, `+`, consts/statics/associated consts, locals + (shadowing-aware, mutated names untrusted), `Url::parse(..)?.join(..)` + (RFC 3986 merge — `…/v2/catalog` + `tags` is `/v2/tags`), and a base URL + held in a struct field when every constructor fills it with the same + literal or const. Anything else stays a dynamic fact (`channel: null`, a + masked `channelPrefix` when proven) or a counted limitation. +- **Wrappers**: functions, methods and struct-literal endpoints declared in + an isthmus `http-wrappers` v1 file (`"language": "rust"`, `owner::name` is + the rustograph vertex id) become calls with the declared verb and anchor. +- **Measured gaps**: unmodelled clients (hyper client, surf, awc, isahc, …), + requests sent from a receiver that is not a proven client, relative URLs + the client rejects, undeclared wrapper sinks and unresolved declarations + are `route-call-coverage:` / `ambiguous-base-join:` / + `http-wrapper-undeclared:` / `http-wrapper-unresolved:` limitations. + +The rules (`Url::join` is the isthmus `rfc3986` join; reqwest and ureq have +no base URL, so full-URL rules apply) and the oracle table are in +[docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md). A mock-server oracle +(`experiments/client-oracle/`) compiles the fixture against the real +reqwest/ureq/url crates and records every request at a local server: 41 +scenarios, 0 mismatches, checked offline by `cargo test`. All 48 +`producer`/`producer:rustograph` cases of the isthmus `url-compose` vectors +pass. + ## Traversal documents — `reach` / `impact --format language-traversal` ```bash @@ -304,9 +348,9 @@ strings as `symbol.usr` in `schema`. `symbol`, a `root-not-found:` limitation is added, and the command exits `64` after writing the document. -isthmus rejects `route-decl` facts from `platform: "rust"` documents, so -Rust handlers cannot yet start a route-selection `trace`; relation and -symbol selections (reverse traversal) work today. +isthmus accepts Rust `route-decl` and (since isthmus #133) `route-call` +documents, so a workspace `trace` joins a reqwest client to an axum/actix-web +handler and continues into the client code with `impact`. ## Agent output contract diff --git a/conformance.lock b/conformance.lock index e46fb05..e66c4b4 100644 --- a/conformance.lock +++ b/conformance.lock @@ -1,10 +1,10 @@ { - "commit": "76b6141e71c84e0ab1026ad1f18f910b9d966dc8", + "commit": "3a4545088e8eaf1095b94765a407a9a0b5d823f2", "files": { "http-dispatch.json": "efeecae0622ac8ee4185d10ea117fb9c3c2b2dc504137cae0abd1841f3414f47", - "http-limitation-scope.json": "757200f40b1fa54a30b9486ff24bf5f89c2f7bc4fac26eb9a79d62004a1c847e", + "http-limitation-scope.json": "a7c6c3916bbf6991f24e2640242fdbe71c881858b47b90d74fba2b832f4f4d3c", "http-template.json": "770f3a79986560579c94b2548d392eb66ef877dc95b6284211551eeb27cf53e6", - "url-compose.json": "afb689685e3dc987d37ce6b8ff32088604956a67dfe8a7e1556aa462f09a1c7a" + "url-compose.json": "ef2ab9523906ec13d383752910e2feca406581b1f85a6bac8fb3b57e97966505" }, "format": "isthmus-conformance-lock", "source": "https://github.com/ictechgy/isthmus", diff --git a/conformance/README.md b/conformance/README.md index 790c414..e9761a6 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,11 +1,11 @@ # isthmus 공유 적합성 벡터 -isthmus(`76b6141e71c84e0ab1026ad1f18f910b9d966dc8`)의 `conformance/`를 그대로 가져온 사본이다. 정본은 isthmus가 +isthmus(`3a4545088e8eaf1095b94765a407a9a0b5d823f2`)의 `conformance/`를 그대로 가져온 사본이다. 정본은 isthmus가 소유하며, 이 디렉터리 파일을 직접 고치지 않는다. 갱신할 때는 isthmus main의 파일과 `SHA256SUMS`를 함께 다시 복사하고(새 suite 파일 포함) 저장소 루트의 `conformance.lock`에 커밋과 파일별 sha256을 적은 뒤 `cargo test --test routes`로 확인한다. -rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role server`): +rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role server|client`): | suite | ruleId | 검사 | |---|---|---| @@ -13,7 +13,9 @@ rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role se | `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`)가 소비자와 같이 판정한다(생산 문서가 거부되지 않게) | +| `url-compose` | `compose.*`·`wrapper.*`(`producer` 41건 + `producer:rustograph` 7건) | 클라이언트 조립(`source::routes::compose`)과 래퍼 동사 바인딩(`source::routes::wrappers`)이 같은 결과를 낸다(`tests/client_routes.rs`). `dio-concat`은 결과가 같은 WHATWG 문자열 연결로, `wrapper.location`은 실제 스캐너로 확인한다 | 건너뛰는 사례와 이유: 소비자 전용 사례(`match.*`, `dispatch.match`, `dispatch.shadow`, `scope.applies` — 적용 판정은 -소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose` 전체(클라이언트 -`route-call` 조립 규칙이며 rustograph는 서버 선언만 낸다). +소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose`의 다른 생산자 +전용 사례(`producer:kartograph`·`producer:gartograph`·`producer:pythograph`), `scope.dynamic-validate`· +`scope.dynamic-applies`(dynamic 선언의 선택 필드 `dynamicScope` — rustograph는 내지 않는다). diff --git a/conformance/SHA256SUMS b/conformance/SHA256SUMS index ff9672d..d1beac8 100644 --- a/conformance/SHA256SUMS +++ b/conformance/SHA256SUMS @@ -1,4 +1,4 @@ efeecae0622ac8ee4185d10ea117fb9c3c2b2dc504137cae0abd1841f3414f47 http-dispatch.json -757200f40b1fa54a30b9486ff24bf5f89c2f7bc4fac26eb9a79d62004a1c847e http-limitation-scope.json +a7c6c3916bbf6991f24e2640242fdbe71c881858b47b90d74fba2b832f4f4d3c http-limitation-scope.json 770f3a79986560579c94b2548d392eb66ef877dc95b6284211551eeb27cf53e6 http-template.json -afb689685e3dc987d37ce6b8ff32088604956a67dfe8a7e1556aa462f09a1c7a url-compose.json +ef2ab9523906ec13d383752910e2feca406581b1f85a6bac8fb3b57e97966505 url-compose.json diff --git a/conformance/http-limitation-scope.json b/conformance/http-limitation-scope.json index d335586..8122c97 100644 --- a/conformance/http-limitation-scope.json +++ b/conformance/http-limitation-scope.json @@ -2,7 +2,7 @@ "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.", + "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; plus the same reading for dynamic declarations that carry a proven dynamicScope (scope.dynamic-*). 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": [ { @@ -684,6 +684,860 @@ "expect": { "valid": true } + }, + { + "id": "dynamic-validate/prefix-on-any-with-methods", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ], + "methods": [ + "GET", + "HEAD" + ] + } + } + }, + "expect": { + "valid": true + } + }, + { + "id": "dynamic-validate/templates-on-get", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templates": [ + "/legacy/{}.php" + ] + } + } + }, + "expect": { + "valid": true + } + }, + { + "id": "dynamic-validate/contract-suffix", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-contract", + "method": "GET", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templateSuffixes": [ + "/reports/{}" + ] + } + } + }, + "expect": { + "valid": true + }, + "note": "Unresolved server variables make a contract base-anchored; its known tail is a suffix." + }, + { + "id": "dynamic-validate/base-root-prefix-with-methods", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "POST" + ] + } + } + }, + "expect": { + "valid": true + } + }, + { + "id": "dynamic-validate/static-declaration", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "root", + "dynamic": false, + "channel": "/static", + "dynamicScope": { + "templatePrefixes": [ + "/static" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "A static declaration is already bounded by its template." + }, + { + "id": "dynamic-validate/route-call", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-call", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/a" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "Dynamic calls keep channelPrefix; dynamicScope is declaration-side only." + }, + { + "id": "dynamic-validate/methods-on-non-any", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/a" + ], + "methods": [ + "GET" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "A non-ANY declaration is already bounded by its method; restating it is redundant and a different set is contradictory." + }, + { + "id": "dynamic-validate/methods-on-contract", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-contract", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/a" + ], + "methods": [ + "GET" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/methods-only", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "methods": [ + "GET" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/empty-object", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": {} + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/limitation-index-key", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "limitationIndex": 0, + "templates": [ + "/a" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/not-an-object", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": [ + "/a" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/lowercase-percent", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templates": [ + "/a%2fb" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/prefix-trailing-slash", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin/" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/prefix-catch-all", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin/{**}" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/base-templates", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templates": [ + "/a" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "Root-relative elements cannot be proven behind an unknown base." + }, + { + "id": "dynamic-validate/base-non-root-prefix", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-applies/unscoped-any-call", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET" + }, + "probe": { + "template": "/anything", + "method": "DELETE", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + }, + "note": "A dynamic declaration without dynamicScope is a gap for every call (backward compatible), even of another method." + }, + { + "id": "dynamic-applies/prefix-inside", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users/{}", + "method": "POST", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/prefix-outside", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/administrator", + "method": "POST", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + }, + "note": "Prefixes are segment-bounded." + }, + { + "id": "dynamic-applies/prefix-case-and-slash-folded", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/Admin/", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/declaration-method-bounds", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users", + "method": "POST", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + }, + "note": "With a dynamicScope, a non-ANY declaration only serves its own method." + }, + { + "id": "dynamic-applies/declaration-method-head-as-get", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users", + "method": "HEAD", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/declaration-method-options", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "POST", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users", + "method": "OPTIONS", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/scope-methods-exclude", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "GET", + "HEAD" + ] + } + }, + "probe": { + "template": "/api/orders", + "method": "PUT", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "dynamic-applies/dynamic-verb-call", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "GET" + ] + } + }, + "probe": { + "template": "/api/orders", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + }, + "note": "A call with a non-literal verb may use any method." + }, + { + "id": "dynamic-applies/partial-segment-template", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templates": [ + "/legacy/{}.php" + ] + } + }, + "probe": { + "template": "/legacy/index.php", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/partial-segment-other", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templates": [ + "/legacy/{}.php" + ] + } + }, + "probe": { + "template": "/legacy/index.html", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "dynamic-applies/base-call-unknown-lead", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/users", + "method": "GET", + "pathAnchor": "base" + } + }, + "expect": { + "applies": true + }, + "note": "A base-anchored call has an unknown leading part, so it may fall under any prefix." + }, + { + "id": "dynamic-applies/suffix-tail", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templateSuffixes": [ + "/reports/{}" + ] + } + }, + "probe": { + "template": "/v2/reports/7", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/suffix-other-tail", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templateSuffixes": [ + "/reports/{}" + ] + } + }, + "probe": { + "template": "/v2/orders/7", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + } } ] } diff --git a/conformance/url-compose.json b/conformance/url-compose.json index 3173e31..40f4d11 100644 --- a/conformance/url-compose.json +++ b/conformance/url-compose.json @@ -1196,6 +1196,753 @@ "expect": { "line": 89 } + }, + { + "id": "base-join/go-resolve-reference-base-directory", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "(*url.URL).ResolveReference merges a relative reference after the last / of the base path (RFC 3986 5.2.3). Reproduced with go1.27.1; go1.23.0 source is identical." + }, + { + "id": "base-join/go-resolve-reference-base-file", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "Without a trailing slash the last base segment is replaced, not extended." + }, + { + "id": "base-join/go-resolve-reference-rooted", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "An absolute-path reference replaces the whole base path." + }, + { + "id": "base-join/go-resolve-reference-dot-segments", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/v1/", + "path": "../users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "remove_dot_segments (RFC 3986 5.2.4) runs after the merge." + }, + { + "id": "base-join/go-resolve-reference-network-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "//other.example.com/x" + }, + "expect": { + "template": "/x", + "pathAnchor": "root", + "authority": "other.example.com" + }, + "note": "A network-path reference (//host/...) replaces the authority as well as the path." + }, + { + "id": "base-join/go-join-path-relative", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "(*url.URL).JoinPath (Go 1.19+) joins with path.Join, so the base path is always extended." + }, + { + "id": "base-join/go-join-path-rooted-element", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "A leading / on an element does not reset the path (unlike ResolveReference)." + }, + { + "id": "base-join/go-join-path-collapses", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "a//b" + }, + "expect": { + "template": "/api/a/b", + "pathAnchor": "root" + }, + "note": "path.Join collapses repeated slashes." + }, + { + "id": "base-join/go-join-path-dot-dot-escapes", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "../x" + }, + "expect": { + "template": "/x", + "pathAnchor": "root" + }, + "note": "path.Join cleans .. against the base path, so it can leave the base path." + }, + { + "id": "base-join/go-join-path-trailing-slash", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "users/" + }, + "expect": { + "template": "/api/users/", + "pathAnchor": "root" + }, + "note": "JoinPath keeps one trailing slash when the last element ends with /." + }, + { + "id": "base-join/go-join-path-host-only", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root", + "authority": "api.example.com" + } + }, + { + "id": "base-join/go-join-path-unknown-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": null, + "path": "/users/" + }, + "expect": { + "template": "/users/", + "pathAnchor": "base" + }, + "note": "Unknown base: the cleaned element path is a tail behind the unknown base path." + }, + { + "id": "base-join/go-join-path-unknown-base-dot-dot", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": null, + "path": "../users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "Unknown base: which segment .. removes cannot be known." + }, + { + "id": "base-join/resty-base-trailing-slash-trimmed", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/client.go#L193-L197", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "SetBaseURL trims trailing slashes and parseRequestURL prefixes a missing / before concatenating (resty v2.17.2; v3.0.0-rc.4 composes the same path). Recorded as the RequestURI seen by a local server." + }, + { + "id": "base-join/resty-rooted-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/middleware.go#L103-L126", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "A leading / does not reset the base path (plain concatenation, not RFC 3986)." + }, + { + "id": "base-join/resty-double-slash-kept", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/middleware.go#L103-L126", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api", + "path": "//users" + }, + "expect": { + "template": "/api//users", + "pathAnchor": "root" + }, + "note": "The concatenation is sent as is; // is not collapsed." + }, + { + "id": "base-join/resty-dot-segments-kept", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/middleware.go#L103-L126", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api/", + "path": "./users" + }, + "expect": { + "template": "/api/./users", + "pathAnchor": "root" + }, + "note": "Dot segments are not removed; the server receives /api/./users." + }, + { + "id": "base-join/resty-unknown-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": null, + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + } + }, + { + "id": "base-join/resty-unknown-base-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": null, + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + }, + "note": "Unlike rfc3986 a rooted path stays behind the unknown base." + }, + { + "id": "base-join/rust-url-join-base-directory", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "url::Url::join parses the input with the base (WHATWG URL Standard); for these http(s) inputs the result equals RFC 3986 resolution. reqwest has no base URL, so this applies only when the code calls Url::join. Reproduced with url 2.5.8." + }, + { + "id": "base-join/rust-url-join-base-file", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "Url::join docs: without a trailing slash the last path component is a file name and is replaced." + }, + { + "id": "base-join/rust-url-join-rooted", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/rust-url-join-dot-segments", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "./users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/rfc3986-unknown-base-dot-segment", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph", + "producer:rustograph", + "producer:pythograph" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "./users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + }, + "note": "./ is removed; the rest is a tail behind the unknown base directory." + }, + { + "id": "base-join/rfc3986-unknown-base-dot-dot", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph", + "producer:rustograph", + "producer:pythograph" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "../users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "Unknown base: which segment .. removes cannot be known." + }, + { + "id": "base-join/rfc3986-unknown-base-rooted-dot-segments", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph", + "producer:rustograph", + "producer:pythograph" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "/a/../users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/httpx-base-without-trailing-slash", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "The base_url setter enforces a trailing / and _merge_url appends the relative path (httpx 0.28.1)." + }, + { + "id": "base-join/httpx-rooted-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "_merge_url strips every leading / of the relative path, so a rooted path does not reset the base path (unlike RFC 3986)." + }, + { + "id": "base-join/httpx-dot-dot-escapes", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_urlparse.py#L447-L475", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api", + "path": "../users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "normalize_path (RFC 3986 5.2.4) runs on the merged URL, so .. can leave the base path." + }, + { + "id": "base-join/httpx-inner-double-slash-kept", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api/", + "path": "a//b" + }, + "expect": { + "template": "/api/a//b", + "pathAnchor": "root" + } + }, + { + "id": "base-join/httpx-network-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api", + "path": "//users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "URL('//users') parses users as a host with an empty path; the request goes to the base path. Producers do not claim a route for it." + }, + { + "id": "base-join/httpx-unknown-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": null, + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + } + }, + { + "id": "base-join/httpx-unknown-base-dot-dot", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": null, + "path": "../users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:" + }, + { + "id": "base-join/aiohttp-base-directory-relative", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L531-L535", + "versionRange": ">=3.11", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "ClientSession._build_url joins with yarl URL.join (RFC 3986). Base URLs with a path and relative paths without a leading / are accepted since aiohttp 3.11. Reproduced with aiohttp 3.14.3 / yarl 1.25.1." + }, + { + "id": "base-join/aiohttp-rooted-replaces-base-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L531-L535", + "versionRange": ">=3.11", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "As in RFC 3986 a rooted path replaces the base path (aiohttp docs: request URLs should have no leading slash)." + }, + { + "id": "base-join/aiohttp-host-only-base", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L531-L535", + "versionRange": ">=3.8", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "The origin-only form accepted since aiohttp 3.8." + }, + { + "id": "base-join/aiohttp-base-path-without-trailing-slash", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L355-L363", + "versionRange": ">=3.11", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com/api", + "path": "users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "ClientSession raises ValueError('base_url must have a trailing /'); no request is made." + }, + { + "id": "base-join/aiohttp-unknown-base-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": null, + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/aiohttp-unknown-base-relative", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": null, + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + } } ] } diff --git a/docs/HTTP-CLIENT.md b/docs/HTTP-CLIENT.md new file mode 100644 index 0000000..afdf214 --- /dev/null +++ b/docs/HTTP-CLIENT.md @@ -0,0 +1,185 @@ +# HTTP 호출 규칙 (`rustograph routes --role client`) + +rustograph가 Rust 클라이언트 코드에서 isthmus http `route-call` 사실을 만드는 규칙과 근거를 적는다. 계약 정본은 +isthmus [GRAPH-EXCHANGE "HTTP 경계"](https://github.com/ictechgy/isthmus/blob/main/docs/GRAPH-EXCHANGE.md)와 +[HTTP-WRAPPERS](https://github.com/ictechgy/isthmus/blob/main/docs/HTTP-WRAPPERS.md)(url-compose 규칙·래퍼 선언)다. +서버 선언 규칙은 [HTTP-ROUTES.md](HTTP-ROUTES.md)에 있다. + +```sh +rustograph routes --role client [--dir DIR] [--out FILE] [--wrappers http-wrappers.json] [--service NAME] +``` + +- 문서: bridge-facts v1, `platform: "rust"`, `target: "http"`, `roles: ["client"]`, `sourceSets.tests: "excluded"`, + 선택 `service`(`--service`). 사실이 0건이어도 target은 `http`다(스캔했으나 호출 없음). `dispatch`는 없다. +- 스캔: 모든 워크스페이스 멤버의 lib·bin 타깃, `impact`와 같은 syn 수확(모듈 트리·AST·정점 집합). `#[cfg(test)]` 모듈과 + `#[test]` 함수는 뺀다. 컴파일·실행·네트워크 접근이 없다. +- `symbol.usr`: 호출을 감싸는 가장 안쪽 그래프 정점(함수·메서드·트레이트 기본 메서드, 상수·static 초기식) — `impact`· + `reach`의 정점 ID와 같다. 중첩 함수·클로저 안의 호출은 바깥 정점이다. 정점이 없으면 usr 없이 내고 + `missing-route-usrs:`로 센다. `verify-cli-contract.sh`가 `impact --roots-from`으로 모든 usr가 정점임을 확인한다. +- `location`: 호출식이 시작하는 줄(`wrapper.location` — 메서드 체인은 수신자 시작)과 UTF-8 바이트 열 + 1. +- dynamic 사실은 `channel: null`이다 — 원문 식은 URL의 userinfo·query를 담을 수 있어 싣지 않는다. 증명한 리터럴 + 접두사만 마스킹한 `channelPrefix`로 싣는다. +- `authority`: base가 리터럴이고 root 앵커일 때만 싣는 소문자 `host[:port]`(userinfo 제거, 포트는 적힌 대로). +- `baseRef`: base URL이 구조체 필드(`app::api::ApiClient::base_url`)나 상수·static(`app::HOST`)에서 오면 그 id다. + 필드 id는 그래프 정점이 아니다 — workspace 매니페스트 `match.baseRefs`에 쓰는 안정 이름이다. + +## 라이브러리와 결합 방식 + +| 라이브러리(확인 버전) | 인식 | URL 해석 | 근거 | +|---|---|---|---| +| reqwest 0.13.5(0.12.28도 오라클 실행) | `reqwest::get`·`reqwest::blocking::get`, `Client`·`blocking::Client`의 `get`·`post`·`put`·`patch`·`delete`·`head`·`request(Method::X, url)`, `Request::new(Method::X, url)` | 문자열은 `url::Url::parse`(WHATWG), `Url`은 그대로 | `src/into_url.rs` `IntoUrlSealed for &str`(`Url::parse(self)` 뒤 `has_host()` 검사), `src/async_impl/client.rs` `execute_request`(http·https 외 scheme 거부) | +| ureq 3.4.2 | `ureq::get`·`post`·`put`·`delete`·`patch`·`head`·`options`·`trace`, `Agent`의 같은 메서드 | `http::Uri` — **점 세그먼트를 지우지 않는다** | `src/lib.rs` `pub fn get(uri: T) where Uri: TryFrom`, 오라클 `ureq_dots` | +| ureq 2.x | 위와 같은 자유 함수·`Agent`, `request(method, url)`·`request_url` | `url::Url::parse`(소스 기준, 실행 미확인 — 버전 한계를 낸다) | — | +| url 2.5.8 | `Url::parse(s)`(reqwest 재수출 `reqwest::Url` 포함), `url.join(p)` 체인 | WHATWG URL Standard | url 크레이트 실행 결과(아래) | + +**결합 방식 이름.** isthmus `3a45450`(HTTP-WRAPPERS "Go, Rust, Python 클라이언트")이 정했다. `url::Url::join`은 +`rfc3986`이다. reqwest·ureq에는 base URL 설정이 없으므로 호출 식의 URL이 곧 요청 URL이고, 전체 URL 리터럴은 +`compose.strip`, 문자열 조립은 보간 규칙을 쓴다. 조립한 문자열의 앞머리가 모르는 값(`format!("{base}/x")`)이면 계약의 +"그 밖" 규칙대로 `/`로 시작하는 뒤 리터럴만 base 앵커 꼬리이고, 아니면 dynamic + `ambiguous-base-join:`이다. + +| 쓰는 곳 | `/x`(미상 base) | `x`(미상 base) | base 리터럴 | +|---|---|---|---| +| `url::Url::join`(`rfc3986`) | root(점 세그먼트 제거) | base, `./`는 지움, `..`·빈 참조는 dynamic + `ambiguous-base-join:` | WHATWG 상대 해석, root | +| 이은 문자열을 reqwest·ureq 2에 넘김(`Url::parse`) | base | dynamic + `ambiguous-base-join:` | 전체 URL을 WHATWG로 해석(점 세그먼트 제거, `//` 보존), root | +| 이은 문자열을 ureq 3에 넘김(`http::Uri`) | base | dynamic + `ambiguous-base-join:` | 전체 URL 그대로(점 세그먼트 보존), root | + +코드 안의 이름은 `Join::WhatwgJoin`(=`rfc3986`), `Join::WhatwgConcat`, `Join::HttpUriConcat`이다. 벡터에는 뒤의 둘을 +가리키는 결합 이름이 없다(전체 URL 규칙이라 필요 없다). + +WHATWG 결합은 http(s)에서 RFC 3986과 같다. 다른 점은 셋이고 모두 구현했다: `\`를 `/`로 읽고, 같은 scheme의 +`http:x`는 상대 참조이며, 앞뒤 C0·공백과 탭·줄바꿈을 지운다. url 2.5.8로 실행해 확인한 값(단위 테스트 +`whatwg_join_matches_url_crate`가 고정): `http://h/api` + `x` → `/x`, `http://h/api/` + `x` → `/api/x`, +`http://h/a/b/c` + `../x` → `/a/x`, `http://h/api` + `\x` → `/x`, `http://h/api/` + `http:x` → `/api/x`, +`http://h/api` + `//other/x` → host `other`의 `/x`, `http://h/api` + `?q=1` → `/api`. + +문자열 연결의 base 미상 행은 dio 단순 연결과 결과가 같아 공유 벡터의 공통(`producer`) `dio-concat` 사례를 +`Join::WhatwgConcat`으로 실행한다. +`slash-join` 사례는 벡터용 `Join::SlashJoin`으로 실행한다(그 방식을 쓰는 Rust 라이브러리는 모델링하지 않았다). + +## 해석과 증명 + +- 문자열: 리터럴, `format!`/`format_args!`(위치·이름·인라인 캡처 인자, `{{`·`}}`), `concat!`, `a + b`, + `to_string`·`to_owned`·`into`·`as_str`·`clone`·`String::from` 같은 전달, 워크스페이스 `const`·`static`·연관 상수 + (`Self::BASE`)를 따라간다. `{:?}`처럼 서식 지정이 있으면 그 값은 모르는 값이고, `{:>1$}`처럼 인자 순서를 바꾸는 + 서식은 문자열 전체를 모른다. +- 지역 변수: 스코프 스택(블록·클로저·`for`·`match` 팔·`if let`/`while let`·let-else)으로 그림자를 따르고, 함수 안에서 + 대입·`&mut`·`push_str` 같은 수정 메서드가 닿는 이름은 믿지 않는다. 매개변수는 출처(`Origin::Param`)를 단 값이다. +- query 꼬리(`compose.suffix`): 끝에 붙은 지역 변수의 `if`/`match` 모든 가지가 `?` 리터럴로 시작하거나 빈 문자열 + (`String::new()` 포함)이면 떼고 `queryTailStripped`를 단다. `else` 없는 `if`는 증명하지 못한다. +- 구조체 필드 base: 크레이트의 모든 구조체 리터럴(`Self { .. }` 포함)이 그 필드를 **같은 리터럴·상수 값**으로 채우면 + 그 값이다(인스턴스와 무관하게 `self.f`·`x.f` 모두). 생성 위치가 매개변수로 채우거나, 값이 둘 이상이거나, + `..rest` 생략, `#[derive(Default)]`, 필드 대입·`&mut`·수정 메서드가 있으면 값을 버리고 base 앵커 + `baseRef`다. + `Url` 필드도 같다(`Url::parse(리터럴).unwrap()`). +- 수신자 판정: 식의 타입을 구문으로 추론한다 — 생성자(`Client::new()`·`Client::builder()…build()?`·`ureq::agent()`· + `Agent::new_with_defaults()`·`AgentBuilder`·`config_builder()…new_agent()`), 타입 주석이 있는 지역 변수·매개변수, + 구조체 필드 타입, 워크스페이스 함수·메서드의 반환 타입, `static`의 선언 타입. `&`·`Arc`·`Rc`·`Box`·`Lazy`· + `LazyLock`·`OnceLock`·`Result`는 벗긴다. 증명하지 못한 수신자에서 보낸 요청 모양 호출(`x.get(u).send()`)은 사실로 + 내지 않고 센다. +- 동사: 메서드 이름, `reqwest::Method::X`·`http::Method::X` 경로, ureq `request("GET", ..)`의 정확한 대문자 리터럴만 + 동사다. 그 밖은 `methodDynamic: true`다. +- base 없는 상대 URL(`client.get("/x")`)과 http(s)가 아닌 scheme은 클라이언트가 보내기 전에 거부하므로 사실을 내지 않고 + 센다(오라클 `relative_url`: 요청 없음). +- 모델링하지 않는 클라이언트(hyper·hyper-util의 client 경로, surf, awc, isahc, attohttpc, minreq, curl, ehttp, + gloo-net, reqwasm, http-client, reqwest-middleware)는 멤버가 직접 의존할 때 경로 사용 위치를 크레이트별로 센다. + +## 래퍼 선언(`http-wrappers` v1)의 Rust 규칙 + +`"language": "rust"` 항목만 적용한다(다른 언어 항목은 공백으로도 세지 않는다). 선언 오류는 종료 코드 2다. + +- `function`: `owner::name`이 rustograph 정점 ID다. 자유 함수는 모듈 경로(`app::net` + `send`), 메서드·연관 함수는 + 타입 ID(`app::api::ApiClient` + `request`), 트레이트 impl 메서드는 `app::api::ApiClient::` + 이름. +- `constructor`: `owner`는 타입 ID. `name`이 타입 이름과 같으면 구조체 리터럴(`Endpoint { verb, path }` — `label`은 + 필드 이름)이나 튜플 구조체 생성(`Ep(m, p)` — `index`)이고, 다르면 연관 함수(`Endpoint::new`)다. +- 인자: Rust에는 이름 붙은 인자가 없으므로 함수·메서드는 `index`(메서드는 수신자를 빼고 0부터, UFCS 호출도 + 같다)로 묶는다. `label`은 구조체 리터럴 필드에만 맞는다. `methodEnum`은 enum case·연관 상수 경로의 마지막 이름 + (`Verb::Post` → `Post`)과 문자열 리터럴을 case로 본다. +- 경로 인자: 전체 URL이면 절대 해석, `/`로 시작하면 선언 `pathAnchor`, 상대 경로는 래퍼 안 결합을 모르므로 dynamic + (base 앵커면 `ambiguous-base-join:`). 래퍼 경로에는 `baseRef`를 싣지 않는다. +- 선언된 래퍼 본문의 dynamic 호출은 내지 않는다(래퍼 호출 사실이 대신한다). 선언되지 않은 함수가 매개변수를 URL + 전체·base 뒤 꼬리로 흘려보내거나 동사 매개변수를 넘기면 `http-wrapper-undeclared:`로 센다(세그먼트 일부를 채우는 + 매개변수는 래퍼 선언으로 풀리지 않아 세지 않는다). +- 정점·구조체에 닿지 않는 선언과 호출 0건 선언은 `http-wrapper-unresolved:`다. `--service`와 다른 `service`를 선언한 + 래퍼가 있으면 isthmus가 문서를 거부하므로 먼저 사용법 오류로 막는다. + +## limitation + +| 접두사 | 뜻 | +|---|---| +| `route-call-coverage:` | 모델링하지 않는 클라이언트 사용 수(크레이트별), 증명하지 못한 수신자의 요청 모양 호출 수, 보내기 전 거부되는 URL 수, 확인한 버전 밖(reqwest 0.12·0.13, ureq 3 밖)의 의존, 위치를 못 찾은 호출, cargo metadata 한계 | +| `ambiguous-base-join:` | 미상 base 뒤 상대 경로 수 | +| `http-wrapper-undeclared:` | 선언되지 않은 래퍼 싱크 수 | +| `http-wrapper-unresolved:` | 정점에 닿지 않거나 호출이 0건인 rust 래퍼 선언(`wrappers[n]`) | +| `missing-route-usrs:` | usr가 없는 사실 수 | + +호출 측 공백은 증명할 수 있는 요청 범위가 없어 `limitationScopes`를 내지 않는다(문서 전체 효과). + +## 공유 적합성 벡터 + +`conformance/url-compose.json`(isthmus `3a45450`)의 `producer` 41건과 `producer:rustograph` 7건(`base-join/rust-url-join-*`· +`rfc3986-unknown-*`)을 `tests/client_routes.rs`가 제품 함수로 실행한다(`compose::compose_path`·`join`·`UrlVal::parse`·`mask`, +`wrappers::bind_method`) — **48/48 통과**. `wrapper.location`은 실제 스캐너로 여러 줄 호출의 시작 줄과 UTF-8 열을 확인한다. +다른 생산자 전용 사례(`producer:kartograph`·`gartograph`·`pythograph`)는 적용하지 않는다. 오라클과 어긋난 벡터는 없다. +벡터를 받으며 고친 것: 미상 base의 `rfc3986` 상대 참조에 `..`가 있거나 참조가 비면 dynamic + `ambiguous-base-join:`이다 +(이전에는 `..`가 알려진 세그먼트 안에서 끝나면 템플릿을 냈다). + +## 모의 서버 오라클 + +`tests/fixture-client/app/`은 각 규칙을 부르는 합성 클라이언트이고, `experiments/client-oracle/`은 그 lib.rs를 +crates.io의 진짜 reqwest 0.13.5·ureq 3.4.2·url 2.5.8로 컴파일해 시나리오를 실행한다. 모든 http 요청은 환경 프록시 +(`HTTP_PROXY`)로 지정한 127.0.0.1 임시 포트의 기록 서버로 간다(ureq 3은 http 요청도 CONNECT 터널을 연다 — 서버가 +터널을 수락하고 안의 평문 요청을 읽는다). 외부 네트워크 요청은 없고, 서버는 오라클 프로세스 안의 스레드라 함께 +끝난다. 판정은 root는 전체 경로, base는 세그먼트 경계 꼬리, `{}`는 비어 있지 않은 세그먼트, authority는 요청 host이고, +정적 사실은 모두 어떤 요청과 맞아야 한다(정밀도). 재기록은 `experiments/client-oracle/run.sh`, 기본 테스트는 커밋된 +기록(`recorded/client.json`)을 스텁 의존(`tests/fixture-client/stubs`, 이름·버전만)으로 해석한 사실과 오프라인 대조한다. + +2026-09-30 기록: **41개 시나리오, 일치 37 · dynamic 3 · 요청 없음 1 · 불일치 0**, 귀속되지 않은 사실 0. 같은 +소스를 reqwest 0.12.28로 바꾼 scratch 실행도 같은 결과였다. + +| 시나리오 | 기록 요청 | route-call 사실 | 결과 | +|---|---|---|---| +| `literal_query` | GET /v1/items | GET `/v1/items` root | 일치 | +| `async_user` | GET /v1/users/7 | GET `/v1/users/{}` root | 일치 | +| `concat_post` | POST /v1/orders | POST `/v1/orders` root | 일치 | +| `put_positional` | PUT /v1/items/5 | PUT `/v1/items/{}` root | 일치 | +| `delete_plus` | DELETE /v1/items/9 | DELETE `/v1/items/{}` root | 일치 | +| `patch_local` | PATCH /v1/profile | PATCH `/v1/profile` root | 일치 | +| `head_health` | HEAD /v1/health | HEAD `/v1/health` root | 일치 | +| `request_options` | OPTIONS /v1/items | OPTIONS `/v1/items` root | 일치 | +| `request_dynamic` | PATCH /v1/verbs | 동사 동적 `/v1/verbs` root | 일치 | +| `join_relative` | GET /v2/users/5 | GET `/v2/users/{}` root | 일치 | +| **`join_replaces_last`**(RFC 3986 병합) | GET /v2/tags | GET `/v2/tags` root | 일치 | +| `join_absolute_path` | GET /root/ping | GET `/root/ping` root | 일치 | +| `dot_segments` | GET /v1/b | GET `/v1/b` root | 일치 | +| **`double_slash`**(`//` 보존) | GET /v3//items | GET `/v3//items` root | 일치 | +| `query_tail` | GET /v1/search | GET `/v1/search` root | 일치 | +| `query_tail_empty` | GET /v1/search | GET `/v1/search` root | 일치 | +| `partial_segment` | GET /v1/files/report.json | GET dynamic, prefix `/v1/files/` | dynamic | +| `unknown_base_rooted` | GET /prefix/v1/status | GET `/v1/status` base | 일치 | +| `unknown_base_glued` | GET /prefixstatus | GET dynamic(`ambiguous-base-join:`) | dynamic | +| `relative_url` | 없음 — 보내기 전 실패 | 없음 | 요청 없음 | +| `masked_token` | GET /v1/tokens/a1b2c3d4e5f6a7b8c9d0 | GET `/v1/tokens/{}` root | 일치 | +| `non_ascii` | GET /v1/caf%C3%A9 | GET `/v1/caf%C3%A9` root | 일치 | +| `userinfo_fragment` | GET /v1/secure | GET `/v1/secure` root | 일치 | +| `with_port` | GET /v1/port | GET `/v1/port` root(authority `api.example.com:8080`) | 일치 | +| `backslashes` | GET /v1/bs | GET `/v1/bs` root | 일치 | +| `builder_client` | GET /v1/built | GET `/v1/built` root | 일치 | +| `struct_field_base` | GET /api/items | GET `/api/items` root | 일치 | +| `struct_field_item` | GET /api/items/42 | GET `/api/items/{}` root | 일치 | +| `struct_field_plus` | POST /api/items | POST `/api/items` root | 일치 | +| `struct_field_query` | GET /api/search | GET `/api/search` root | 일치 | +| `struct_param_base` | GET /remote/users/3 | GET `/users/{}` base | 일치 | +| `url_field` | GET /cat/products | GET `/cat/products` root | 일치 | +| `ureq_get` | GET /v1/ureq/items | GET `/v1/ureq/items` root | 일치 | +| `ureq_delete` | DELETE /v1/ureq/items/4 | DELETE `/v1/ureq/items/{}` root | 일치 | +| **`ureq_dots`**(점 세그먼트 보존) | GET /v1/x/../ureq-dots | GET `/v1/x/../ureq-dots` root | 일치 | +| `ureq_agent_head` | HEAD /v1/ureq/health | HEAD `/v1/ureq/health` root | 일치 | +| `ureq_post` | POST /v1/ureq/orders | POST `/v1/ureq/orders` root | 일치 | +| `wrapper_function` | GET /wb/w/items | GET `/w/items` base | 일치 | +| `wrapper_method` | POST /api/w/orders | POST `/w/orders` base | 일치 | +| `wrapper_constructor` | DELETE /api/e/items/6 | DELETE `/e/items/{}` base(+ `execute`의 동사 동적 dynamic) | 일치 | +| `undeclared_sink` | GET /v1/undeclared | GET dynamic(`http-wrapper-undeclared:`) | dynamic | + +## isthmus 호환 + +isthmus `3a45450`(#133)부터 rust `route-call`을 받는다(그 전 main `c395c59`는 "Fact kind is not valid for platform"으로 +거부했다). 무패치 `3a45450` 빌드로 fixture 문서와 axum08 서버 문서를 workspace `trace`에 넣어 확인했다(HANDOFF의 e2e 절). diff --git a/experiments/client-oracle/Cargo.lock b/experiments/client-oracle/Cargo.lock new file mode 100644 index 0000000..ba17c4b --- /dev/null +++ b/experiments/client-oracle/Cargo.lock @@ -0,0 +1,892 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[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-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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", +] + +[[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", + "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 = "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", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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 = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oracle-client" +version = "0.0.0" +dependencies = [ + "reqwest", + "serde_json", + "tokio", + "ureq", + "url", +] + +[[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 = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[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", +] + +[[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 = "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 = "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" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = [ + "libc", + "mio", + "pin-project-lite", + "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", +] + +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "pin-project-lite", + "tracing-core", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[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-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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", + "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", + "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", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/experiments/client-oracle/Cargo.toml b/experiments/client-oracle/Cargo.toml new file mode 100644 index 0000000..3f719d4 --- /dev/null +++ b/experiments/client-oracle/Cargo.toml @@ -0,0 +1,28 @@ +# 클라이언트 오라클 — fixture의 같은 소스를 crates.io의 진짜 reqwest·ureq·url로 +# 컴파일해 127.0.0.1 임시 포트의 기록 서버로 요청을 보내고, rustograph +# `routes --role client`가 낸 사실과 대조한다. rustograph 본체와 별개인 독립 +# 워크스페이스다(본체는 이 의존성을 모른다). +[workspace] + +[package] +name = "oracle-client" +version = "0.0.0" +edition = "2021" +publish = false + +# lib은 fixture 소스 그대로다 — 오라클이 보내는 요청과 rustograph가 읽은 호출이 +# 같은 코드에서 나온다. +[lib] +name = "client_app" +path = "../../tests/fixture-client/app/client_app/src/lib.rs" + +[[bin]] +name = "oracle-client" +path = "src/main.rs" + +[dependencies] +reqwest = { version = "=0.13.5", default-features = false, features = ["blocking"] } +ureq = { version = "=3.4.2", default-features = false } +url = "=2.5.8" +tokio = { version = "1", features = ["rt", "macros"] } +serde_json = "1" diff --git a/experiments/client-oracle/recorded/client.json b/experiments/client-oracle/recorded/client.json new file mode 100644 index 0000000..d032426 --- /dev/null +++ b/experiments/client-oracle/recorded/client.json @@ -0,0 +1,910 @@ +{ + "scenarios": [ + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::literal_query" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/items" + } + ], + "result": "match", + "scenario": "literal_query" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/users/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::async_user" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/users/7" + } + ], + "result": "match", + "scenario": "async_user" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/orders", + "dynamic": false, + "method": "POST", + "pathAnchor": "root", + "usr": "client_app::scenarios::concat_post" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/v1/orders" + } + ], + "result": "match", + "scenario": "concat_post" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items/{}", + "dynamic": false, + "method": "PUT", + "pathAnchor": "root", + "usr": "client_app::scenarios::put_positional" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "PUT", + "path": "/v1/items/5" + } + ], + "result": "match", + "scenario": "put_positional" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items/{}", + "dynamic": false, + "method": "DELETE", + "pathAnchor": "root", + "usr": "client_app::scenarios::delete_plus" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "DELETE", + "path": "/v1/items/9" + } + ], + "result": "match", + "scenario": "delete_plus" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/profile", + "dynamic": false, + "method": "PATCH", + "pathAnchor": "root", + "usr": "client_app::scenarios::patch_local" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "PATCH", + "path": "/v1/profile" + } + ], + "result": "match", + "scenario": "patch_local" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/health", + "dynamic": false, + "method": "HEAD", + "pathAnchor": "root", + "usr": "client_app::scenarios::head_health" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "HEAD", + "path": "/v1/health" + } + ], + "result": "match", + "scenario": "head_health" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items", + "dynamic": false, + "method": "OPTIONS", + "pathAnchor": "root", + "usr": "client_app::scenarios::request_options" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "OPTIONS", + "path": "/v1/items" + } + ], + "result": "match", + "scenario": "request_options" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/verbs", + "dynamic": false, + "methodDynamic": true, + "pathAnchor": "root", + "usr": "client_app::scenarios::request_dynamic" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "PATCH", + "path": "/v1/verbs" + } + ], + "result": "match", + "scenario": "request_dynamic" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v2/users/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::join_relative" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v2/users/5" + } + ], + "result": "match", + "scenario": "join_relative" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v2/tags", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::join_replaces_last" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v2/tags" + } + ], + "result": "match", + "scenario": "join_replaces_last" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/root/ping", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::join_absolute_path" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/root/ping" + } + ], + "result": "match", + "scenario": "join_absolute_path" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/b", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::dot_segments" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/b" + } + ], + "result": "match", + "scenario": "dot_segments" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v3//items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::double_slash" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v3//items" + } + ], + "result": "match", + "scenario": "double_slash" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/search", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::query_tail" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/search" + } + ], + "result": "match", + "scenario": "query_tail" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/search", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::query_tail" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/search" + } + ], + "result": "match", + "scenario": "query_tail_empty" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "channelPrefix": "/v1/files/", + "dynamic": true, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::partial_segment" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/files/report.json" + } + ], + "result": "dynamic", + "scenario": "partial_segment" + }, + { + "error": false, + "facts": [ + { + "channel": "/v1/status", + "dynamic": false, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::scenarios::unknown_base_rooted" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/prefix/v1/status" + } + ], + "result": "match", + "scenario": "unknown_base_rooted" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "dynamic": true, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::scenarios::unknown_base_glued" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/prefixstatus" + } + ], + "result": "dynamic", + "scenario": "unknown_base_glued" + }, + { + "error": true, + "facts": [], + "requests": [], + "result": "no-request", + "scenario": "relative_url" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/tokens/{}", + "dynamic": false, + "maskedSegments": 1, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::masked_token" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/tokens/a1b2c3d4e5f6a7b8c9d0" + } + ], + "result": "match", + "scenario": "masked_token" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/caf%C3%A9", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::non_ascii" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/caf%C3%A9" + } + ], + "result": "match", + "scenario": "non_ascii" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/secure", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::userinfo_fragment" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/secure" + } + ], + "result": "match", + "scenario": "userinfo_fragment" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com:8080", + "channel": "/v1/port", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::with_port" + } + ], + "requests": [ + { + "authority": "api.example.com:8080", + "method": "GET", + "path": "/v1/port" + } + ], + "result": "match", + "scenario": "with_port" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/bs", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::backslashes" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/bs" + } + ], + "result": "match", + "scenario": "backslashes" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/built", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::builder_client" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/built" + } + ], + "result": "match", + "scenario": "builder_client" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::api::ApiClient::list_items" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/api/items" + } + ], + "result": "match", + "scenario": "struct_field_base" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/items/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::api::ApiClient::get_item" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/api/items/42" + } + ], + "result": "match", + "scenario": "struct_field_item" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/items", + "dynamic": false, + "method": "POST", + "pathAnchor": "root", + "usr": "client_app::api::ApiClient::create_item" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/api/items" + } + ], + "result": "match", + "scenario": "struct_field_plus" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/search", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::api::ApiClient::search" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/api/search" + } + ], + "result": "match", + "scenario": "struct_field_query" + }, + { + "error": false, + "facts": [ + { + "channel": "/users/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::api::RemoteClient::user" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/remote/users/3" + } + ], + "result": "match", + "scenario": "struct_param_base" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/cat/products", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::api::Catalog::products" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/cat/products" + } + ], + "result": "match", + "scenario": "url_field" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_get" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/ureq/items" + } + ], + "result": "match", + "scenario": "ureq_get" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/items/{}", + "dynamic": false, + "method": "DELETE", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_delete" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "DELETE", + "path": "/v1/ureq/items/4" + } + ], + "result": "match", + "scenario": "ureq_delete" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/x/../ureq-dots", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_dots" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/x/../ureq-dots" + } + ], + "result": "match", + "scenario": "ureq_dots" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/health", + "dynamic": false, + "method": "HEAD", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_agent_head" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "HEAD", + "path": "/v1/ureq/health" + } + ], + "result": "match", + "scenario": "ureq_agent_head" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/orders", + "dynamic": false, + "method": "POST", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_post" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/v1/ureq/orders" + } + ], + "result": "match", + "scenario": "ureq_post" + }, + { + "error": false, + "facts": [ + { + "channel": "/w/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::wrappers::wrapper_function" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/wb/w/items" + } + ], + "result": "match", + "scenario": "wrapper_function" + }, + { + "error": false, + "facts": [ + { + "channel": "/w/orders", + "dynamic": false, + "method": "POST", + "pathAnchor": "base", + "usr": "client_app::wrappers::wrapper_method" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/api/w/orders" + } + ], + "result": "match", + "scenario": "wrapper_method" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "dynamic": true, + "methodDynamic": true, + "pathAnchor": "base", + "usr": "client_app::api::ApiClient::execute" + }, + { + "channel": "/e/items/{}", + "dynamic": false, + "method": "DELETE", + "pathAnchor": "base", + "usr": "client_app::wrappers::wrapper_constructor" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "DELETE", + "path": "/api/e/items/6" + } + ], + "result": "match", + "scenario": "wrapper_constructor" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "dynamic": true, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::wrappers::raw_get" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/undeclared" + } + ], + "result": "dynamic", + "scenario": "undeclared_sink" + } + ], + "totals": { + "dynamic": 3, + "match": 37, + "mismatch": 0, + "noRequest": 1, + "scenarios": 41 + }, + "unclaimedFacts": [], + "versions": { + "reqwest": "0.13.5", + "ureq": "3.4.2", + "url": "2.5.8" + } +} diff --git a/experiments/client-oracle/run.sh b/experiments/client-oracle/run.sh new file mode 100755 index 0000000..d71a491 --- /dev/null +++ b/experiments/client-oracle/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# 오라클 재기록: fixture에 `rustograph routes --role client`를 돌리고, 같은 소스를 진짜 +# reqwest·ureq·url로 실행해 로컬 기록 서버가 받은 요청과 대조한 뒤 recorded/client.json을 +# 쓴다. crates.io 의존성 내려받기 외에 네트워크를 쓰지 않는다(요청은 127.0.0.1 프록시로 간다). +# 불일치·귀속되지 않은 사실이 하나라도 있으면 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" +fixture="$repo/tests/fixture-client" +"$repo/target/debug/rustograph" routes --role client --dir "$fixture/app" \ + --wrappers "$fixture/http-wrappers.json" --out "$tmp/client.json" >/dev/null +status=0 +"$here/target/release/oracle-client" "$tmp/client.json" "$here/recorded/client.json" || status=1 +rm -rf "$tmp" +exit $status diff --git a/experiments/client-oracle/src/main.rs b/experiments/client-oracle/src/main.rs new file mode 100644 index 0000000..7e90c23 --- /dev/null +++ b/experiments/client-oracle/src/main.rs @@ -0,0 +1,451 @@ +//! 클라이언트 오라클 — fixture 시나리오를 진짜 reqwest 0.13.5·ureq 3.4.2·url 2.5.8로 +//! 실행해 127.0.0.1 임시 포트의 기록 서버(HTTP 프록시로 지정)가 받은 method·경로를 +//! rustograph `routes --role client` 사실과 대조한다. +//! +//! 판정: 시나리오의 요청마다 그 시나리오에 귀속된 정적 사실 중 method(또는 +//! `methodDynamic`)와 경로(root는 전체, base는 세그먼트 경계 꼬리, `{}`는 비어 있지 +//! 않은 세그먼트)와 authority가 맞는 것이 있으면 match, 정적 사실 없이 dynamic +//! 사실만 있으면 dynamic, 요청이 없으면(보내기 전 실패) 사실도 없어야 한다. 정적 +//! 사실은 모두 어떤 요청과 맞아야 한다(정밀도). 외부 네트워크를 쓰지 않는다 — +//! 모든 http 요청이 프록시로 지정한 로컬 서버로 간다. +//! +//! 사용: oracle-client + +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +/// 서버가 받은 요청 하나. +#[derive(Clone, Debug)] +struct Seen { + method: String, + target: String, + host: String, +} + +type Log = Arc>>; + +fn main() { + let args: Vec = std::env::args().collect(); + let [_, facts_path, out_path] = args.as_slice() else { + eprintln!("usage: oracle-client "); + std::process::exit(2); + }; + let doc: Value = serde_json::from_str( + &std::fs::read_to_string(facts_path).expect("read the routes document"), + ) + .expect("routes document is JSON"); + let log: Log = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1"); + let port = listener.local_addr().expect("local addr").port(); + let server_log = log.clone(); + std::thread::spawn(move || serve(listener, server_log)); + // 모든 http 요청을 기록 서버로 보낸다(reqwest·ureq 모두 환경 프록시를 읽는다). + let proxy = format!("http://127.0.0.1:{port}"); + for key in ["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"] { + std::env::set_var(key, &proxy); + } + for key in ["NO_PROXY", "no_proxy"] { + std::env::remove_var(key); + } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let mut rows = Vec::new(); + let mut claimed = std::collections::BTreeSet::new(); + let mut failed = 0usize; + for sc in scenarios() { + log.lock().unwrap().clear(); + let error = match &sc.run { + Run::Blocking(f) => f().err(), + Run::Async(f) => rt.block_on(f()).err(), + }; + // 서버 스레드가 기록을 마칠 때까지 잠깐 기다린다(응답 뒤 기록하지 않으므로 짧다). + std::thread::sleep(std::time::Duration::from_millis(30)); + let seen = log.lock().unwrap().clone(); + let facts = facts_for(&doc, &sc.usrs); + for f in &facts { + claimed.insert(f.to_string()); + } + let (result, requests) = judge(&seen, &facts); + if result == "mismatch" { + failed += 1; + } + rows.push(json!({ + "scenario": sc.name, + "requests": requests, + "facts": facts, + "result": result, + "error": error.is_some(), + })); + } + let unclaimed: Vec = doc["facts"] + .as_array() + .into_iter() + .flatten() + .map(summary) + .filter(|f| !claimed.contains(&f.to_string())) + .collect(); + let count = |r: &str| rows.iter().filter(|x| x["result"] == r).count(); + let record = json!({ + "versions": {"reqwest": "0.13.5", "ureq": "3.4.2", "url": "2.5.8"}, + "totals": { + "scenarios": rows.len(), + "match": count("match"), + "dynamic": count("dynamic"), + "noRequest": count("no-request"), + "mismatch": count("mismatch"), + }, + "scenarios": rows, + "unclaimedFacts": unclaimed, + }); + let text = serde_json::to_string_pretty(&record).expect("serialize") + "\n"; + std::fs::write(out_path, text).expect("write the record"); + println!( + "client oracle: {} scenarios — match {}, dynamic {}, no-request {}, mismatch {}, unclaimed facts {}", + record["totals"]["scenarios"], + count("match"), + count("dynamic"), + count("no-request"), + failed, + record["unclaimedFacts"].as_array().map_or(0, Vec::len) + ); + if failed > 0 || !record["unclaimedFacts"].as_array().is_some_and(Vec::is_empty) { + std::process::exit(1); + } +} + +// ── 기록 서버 ──────────────────────────────────────────────── + +/// 연결마다 요청 하나를 읽고 200으로 닫는다. ureq 3은 http 요청도 프록시에 CONNECT +/// 터널을 연다 — 터널을 수락하고 그 안의 평문 요청을 읽는다. +fn serve(listener: TcpListener, log: Log) { + for stream in listener.incoming().flatten() { + let log = log.clone(); + std::thread::spawn(move || { + let mut reader = BufReader::new(&stream); + let Some(mut seen) = read_request(&mut reader) else { + return; + }; + if seen.method == "CONNECT" { + let mut w = &stream; + if w.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").is_err() { + return; + } + let Some(inner) = read_request(&mut reader) else { + return; + }; + seen = inner; + } + log.lock().unwrap().push(seen); + let mut w = &stream; + let _ = w.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + } +} + +/// 요청 줄·헤더·본문을 읽는다. +fn read_request(reader: &mut BufReader<&TcpStream>) -> Option { + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + let mut host = String::new(); + let mut length = 0usize; + loop { + let mut h = String::new(); + if reader.read_line(&mut h).ok()? == 0 || h == "\r\n" || h == "\n" { + break; + } + let (k, v) = h.split_once(':')?; + match k.trim().to_ascii_lowercase().as_str() { + "host" => host = v.trim().to_ascii_lowercase(), + "content-length" => length = v.trim().parse().unwrap_or(0), + _ => {} + } + } + let mut body = vec![0u8; length]; + reader.read_exact(&mut body).ok()?; + Some(Seen { + method, + target, + host, + }) +} + +// ── 시나리오 ───────────────────────────────────────────────── + +type BlockingFn = Box Result<(), String>>; +type AsyncFn = Box std::pin::Pin>>>>; + +enum Run { + Blocking(BlockingFn), + Async(AsyncFn), +} + +/// 시나리오 하나 — 요청을 보내는 함수와 그 요청을 설명해야 하는 사실의 usr들. +struct Scenario { + name: &'static str, + usrs: Vec, + run: Run, +} + +fn b(name: &'static str, usrs: &[&str], f: F) -> Scenario +where + F: Fn() -> client_app::Res + 'static, +{ + Scenario { + name, + usrs: usrs.iter().map(|u| format!("client_app::{u}")).collect(), + run: Run::Blocking(Box::new(move || f().map_err(|e| e.to_string()))), + } +} + +fn scenarios() -> Vec { + use client_app::{scenarios as s, ureq_calls as u, wrappers as w}; + let base = "http://api.example.com/prefix"; + vec![ + b("literal_query", &["scenarios::literal_query"], s::literal_query), + Scenario { + name: "async_user", + usrs: vec!["client_app::scenarios::async_user".into()], + run: Run::Async(Box::new(|| { + Box::pin(async { s::async_user(7).await.map_err(|e| e.to_string()) }) + })), + }, + b("concat_post", &["scenarios::concat_post"], s::concat_post), + b("put_positional", &["scenarios::put_positional"], || s::put_positional(5)), + b("delete_plus", &["scenarios::delete_plus"], || s::delete_plus("9")), + b("patch_local", &["scenarios::patch_local"], s::patch_local), + b("head_health", &["scenarios::head_health"], s::head_health), + b("request_options", &["scenarios::request_options"], s::request_options), + b("request_dynamic", &["scenarios::request_dynamic"], || { + s::request_dynamic(reqwest::Method::PATCH) + }), + b("join_relative", &["scenarios::join_relative"], || s::join_relative(5)), + b("join_replaces_last", &["scenarios::join_replaces_last"], s::join_replaces_last), + b("join_absolute_path", &["scenarios::join_absolute_path"], s::join_absolute_path), + b("dot_segments", &["scenarios::dot_segments"], s::dot_segments), + b("double_slash", &["scenarios::double_slash"], s::double_slash), + b("query_tail", &["scenarios::query_tail"], || s::query_tail(Some(2))), + b("query_tail_empty", &["scenarios::query_tail"], || s::query_tail(None)), + b("partial_segment", &["scenarios::partial_segment"], || s::partial_segment("report")), + b("unknown_base_rooted", &["scenarios::unknown_base_rooted"], move || { + s::unknown_base_rooted(base) + }), + b("unknown_base_glued", &["scenarios::unknown_base_glued"], move || { + s::unknown_base_glued(base) + }), + b("relative_url", &["scenarios::relative_url"], s::relative_url), + b("masked_token", &["scenarios::masked_token"], s::masked_token), + b("non_ascii", &["scenarios::non_ascii"], s::non_ascii), + b("userinfo_fragment", &["scenarios::userinfo_fragment"], s::userinfo_fragment), + b("with_port", &["scenarios::with_port"], s::with_port), + b("backslashes", &["scenarios::backslashes"], s::backslashes), + b("builder_client", &["scenarios::builder_client"], s::builder_client), + b("struct_field_base", &["api::ApiClient::list_items"], s::struct_field_base), + b("struct_field_item", &["api::ApiClient::get_item"], s::struct_field_item), + b("struct_field_plus", &["api::ApiClient::create_item"], s::struct_field_plus), + b("struct_field_query", &["api::ApiClient::search"], s::struct_field_query), + b("struct_param_base", &["api::RemoteClient::user"], move || { + s::struct_param_base("http://api.example.com/remote") + }), + b("url_field", &["api::Catalog::products"], s::url_field), + b("ureq_get", &["ureq_calls::ureq_get"], u::ureq_get), + b("ureq_delete", &["ureq_calls::ureq_delete"], || u::ureq_delete(4)), + b("ureq_dots", &["ureq_calls::ureq_dots"], u::ureq_dots), + b("ureq_agent_head", &["ureq_calls::ureq_agent_head"], u::ureq_agent_head), + b("ureq_post", &["ureq_calls::ureq_post"], u::ureq_post), + b("wrapper_function", &["wrappers::wrapper_function"], w::wrapper_function), + b("wrapper_method", &["wrappers::wrapper_method"], w::wrapper_method), + b( + "wrapper_constructor", + &["wrappers::wrapper_constructor", "api::ApiClient::execute"], + || w::wrapper_constructor(6), + ), + b("undeclared_sink", &["wrappers::raw_get"], w::undeclared_sink), + ] +} + +// ── 판정 ───────────────────────────────────────────────────── + +/// 사실을 오라클이 비교하는 필드만 남긴 요약으로. +fn summary(f: &Value) -> Value { + let mut out = json!({ + "channel": f["channel"], + "dynamic": f["dynamic"], + "pathAnchor": f["pathAnchor"], + "usr": f.pointer("/symbol/usr").cloned().unwrap_or(Value::Null), + }); + for key in [ + "method", + "methodDynamic", + "authority", + "channelPrefix", + "queryTailStripped", + "maskedSegments", + ] { + if let Some(v) = f.get(key) { + out[key] = v.clone(); + } + } + out +} + +/// 시나리오 usr들에 귀속된 사실 요약. +fn facts_for(doc: &Value, usrs: &[String]) -> Vec { + doc["facts"] + .as_array() + .into_iter() + .flatten() + .filter(|f| { + f.pointer("/symbol/usr") + .and_then(Value::as_str) + .is_some_and(|u| usrs.iter().any(|x| x == u)) + }) + .map(summary) + .collect() +} + +/// 요청들과 사실들을 대조한다. (결과, 요청 요약). +fn judge(seen: &[Seen], facts: &[Value]) -> (&'static str, Vec) { + let mut requests = Vec::new(); + let statics: Vec<&Value> = facts.iter().filter(|f| f["dynamic"] == false).collect(); + let mut all_matched = true; + let mut any_dynamic_only = false; + let mut used = vec![false; statics.len()]; + for s in seen { + let (authority, path) = split_target(&s.target, &s.host); + let path = normalize(&path); + let hit = statics + .iter() + .position(|f| fact_matches(f, &s.method, &path, &authority)); + match hit { + Some(i) => used[i] = true, + None => { + let dynamic_ok = facts.iter().any(|f| { + f["dynamic"] == true + && method_ok(f, &s.method) + && f["channelPrefix"] + .as_str() + .is_none_or(|p| prefix_ok(f, p, &path)) + }); + if dynamic_ok && statics.is_empty() { + any_dynamic_only = true; + } else { + all_matched = false; + } + } + } + requests.push(json!({"method": s.method, "path": path, "authority": authority})); + } + let precise = used.iter().all(|u| *u); + let result = if seen.is_empty() { + if facts.is_empty() { + "no-request" + } else { + "mismatch" + } + } else if !all_matched || !precise { + "mismatch" + } else if any_dynamic_only { + "dynamic" + } else { + "match" + }; + (result, requests) +} + +/// 프록시 요청(절대 형식)이면 authority와 경로를, 아니면 Host 헤더와 경로를. +fn split_target(target: &str, host: &str) -> (String, String) { + let (authority, rest) = match target.strip_prefix("http://") { + Some(r) => match r.find(['/', '?', '#']) { + Some(i) => (r[..i].to_ascii_lowercase(), r[i..].to_string()), + None => (r.to_ascii_lowercase(), "/".to_string()), + }, + None => (host.to_string(), target.to_string()), + }; + let path = rest.split(['?', '#']).next().unwrap_or("/").to_string(); + let path = if path.is_empty() { "/".into() } else { path }; + (authority, path) +} + +/// 서버가 받은 경로를 정규 표기로(unreserved 디코드, 대문자 hex, 비 pchar 인코딩). +fn normalize(path: &str) -> String { + let bytes = path.as_bytes(); + let mut out = String::new(); + let mut i = 0; + let unreserved = |c: u8| c.is_ascii_alphanumeric() || b"-._~".contains(&c); + let pchar = |c: u8| unreserved(c) || b"!$&'()*+,;=:@/".contains(&c); + while i < bytes.len() { + let c = bytes[i]; + if c == b'%' + && i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit() + { + let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8; + let v = hex(bytes[i + 1]) * 16 + hex(bytes[i + 2]); + if unreserved(v) { + out.push(v as char); + } else { + out.push_str(&format!("%{v:02X}")); + } + i += 3; + continue; + } + if pchar(c) { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } + i += 1; + } + out +} + +fn method_ok(f: &Value, method: &str) -> bool { + f["methodDynamic"] == true || f["method"] == method +} + +/// 정적 사실이 요청을 설명하는가. +fn fact_matches(f: &Value, method: &str, path: &str, authority: &str) -> bool { + if !method_ok(f, method) { + return false; + } + if let Some(a) = f["authority"].as_str() { + if a != authority { + return false; + } + } + let Some(t) = f["channel"].as_str() else { + return false; + }; + let tsegs: Vec<&str> = t[1..].split('/').collect(); + let psegs: Vec<&str> = path[1..].split('/').collect(); + let seg_ok = |t: &str, p: &str| if t == "{}" { !p.is_empty() } else { t == p }; + match f["pathAnchor"].as_str() { + Some("root") => { + tsegs.len() == psegs.len() && tsegs.iter().zip(&psegs).all(|(t, p)| seg_ok(t, p)) + } + _ => { + psegs.len() >= tsegs.len() + && tsegs + .iter() + .zip(&psegs[psegs.len() - tsegs.len()..]) + .all(|(t, p)| seg_ok(t, p)) + } + } +} + +/// dynamic 사실의 channelPrefix가 요청 경로와 맞는가(root는 앞, base는 어딘가). +fn prefix_ok(f: &Value, prefix: &str, path: &str) -> bool { + let literal = prefix.split("{}").next().unwrap_or(prefix); + match f["pathAnchor"].as_str() { + Some("root") => path.starts_with(literal), + _ => path.contains(literal), + } +} diff --git a/scripts/verify-cli-contract.sh b/scripts/verify-cli-contract.sh index 24244c3..a267c14 100755 --- a/scripts/verify-cli-contract.sh +++ b/scripts/verify-cli-contract.sh @@ -167,12 +167,17 @@ if [ "$got" -ne 0 ]; then fails=$((fails+1)) fi -# routes --role server — isthmus http 서버 문서. --role은 필수이고 server만 -# 받는다(생략을 server로 읽으면 client를 더할 때 명령의 뜻이 바뀐다). -# 사실 0건이어도 roles가 있으니 target은 http다(계약의 http 예외). +# routes --role server|client — isthmus http 문서. --role은 필수다(생략을 한쪽으로 +# 읽으면 명령의 뜻이 역할에 따라 흔들린다). 사실 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 role" routes --role proxy +check 0 "routes client" routes --role client +check 2 "routes client fw" routes --role client --framework axum +check 2 "routes server wrap" routes --role server --wrappers x.json +check 2 "routes client wrap" routes --role client --wrappers /nonexistent-xyz.json +check 2 "routes client svc" routes --role client --service "" 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 @@ -189,6 +194,37 @@ routes_field "routes platform" '"platform": "rust"' "$FIX/fix 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" +# 클라이언트 문서 — reqwest·ureq 호출과 선언된 래퍼. 선언 오류는 사용법 오류(2)다. +cp -R tests/fixture-client "$FIX/fixture-client" +client_field() { # client_field <설명> <패턴> <추가 인자...> + local desc="$1" pat="$2"; shift 2 + "$BIN" routes --role client --dir "$FIX/fixture-client/app" "$@" 2>/dev/null | grep -q "$pat" || { + echo "FAIL $desc: missing $pat" >&2 + fails=$((fails+1)) + } +} +client_field "routes client roles" '"client"' +client_field "routes client call" '"kind": "route-call"' +client_field "routes client wrapper" '"/w/orders"' --wrappers "$FIX/fixture-client/http-wrappers.json" +client_field "routes client service" '"service": "mobile"' --service mobile +echo '{"format":"http-wrappers","version":1,"wrappers":[],"extra":1}' > "$FIX/bad-wrappers.json" +got=0 +"$BIN" routes --role client --dir "$FIX/fixture-client/app" --wrappers "$FIX/bad-wrappers.json" >/dev/null 2>&1 || got=$? +if [ "$got" -ne 2 ]; then + echo "FAIL routes client bad wrappers: expected 2, got $got" >&2 + fails=$((fails+1)) +fi +# 호출 usr도 그래프 정점이어야 impact로 이어진다. +"$BIN" routes --role client --dir "$FIX/fixture-client/app" --out "$FIX/client.json" >/dev/null 2>&1 || { + echo "FAIL routes client --out: document not written" >&2 + fails=$((fails+1)) +} +got=0 +"$BIN" impact --format language-traversal --dir "$FIX/fixture-client/app" --roots-from "$FIX/client.json" >/dev/null 2>&1 || got=$? +if [ "$got" -ne 0 ]; then + echo "FAIL routes client usr roots: expected 0 (every call usr is a vertex), got $got" >&2 + fails=$((fails+1)) +fi # 핸들러 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 @@ -203,7 +239,7 @@ fi # --dir를 붙이지 않는 검사 — check()는 항상 fixture dir을 뒤에 붙이므로 # 나쁜 --dir 검증은 마지막 인자가 이기는(last-wins) 구조상 여기서 따로 한다. -for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz" "routes --role server --dir /nonexistent-xyz"; do +for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz" "routes --role server --dir /nonexistent-xyz" "routes --role client --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 a083b77..457e2b6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -34,6 +34,8 @@ usage: 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 routes --role client [--dir DIR] [--out FILE] [--wrappers FILE] + [--service NAME] rustograph version shared flags: --deps --tests --retain-public --semantic --no-cache @@ -151,23 +153,38 @@ 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를 더할 때 같은 명령의 뜻이 바뀐다. +/// `routes --role server|client` — isthmus http 도메인 문서를 낸다. 서버는 axum· +/// actix-web `route-decl`, 클라이언트는 reqwest·ureq·선언된 래퍼의 `route-call`이다. +/// `--role`은 필수다 — 생략을 한쪽으로 읽으면 명령의 뜻이 역할에 따라 흔들린다. 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") => {} + let text = match a.get("role") { + Some("server") => routes_server(a)?, + Some("client") => routes_client(a)?, Some(other) => { return Err(format!( - "routes --role {other} is not supported; only --role server is implemented" + "routes --role {other} is not supported; use --role server or --role client" )) } - None => return Err("routes requires --role server".to_string()), + None => return Err("routes requires --role server or --role client".to_string()), + }; + 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) +} + +/// 서버 route-decl 문서(JSON 텍스트). +fn routes_server(a: &Args) -> Result { + if let Some(bad) = a.unsupported(&["dir", "out", "role", "framework"]) { + return Err(format!( + "routes --role server takes only --dir, --out and --framework — {bad} is not supported" + )); } let framework = match a.get("framework") { None => None, @@ -179,17 +196,34 @@ fn cmd_routes(a: &Args, out: &mut dyn Write) -> Result { 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") { + Ok(export::to_json(&doc)) +} + +/// 클라이언트 route-call 문서(JSON 텍스트). +fn routes_client(a: &Args) -> Result { + if let Some(bad) = a.unsupported(&["dir", "out", "role", "wrappers", "service"]) { + return Err(format!( + "routes --role client takes only --dir, --out, --wrappers and --service — {bad} is not supported" + )); + } + let wrappers = match a.get("wrappers") { + None => Vec::new(), Some(p) => { - std::fs::write(p, &text).map_err(|e| format!("cannot write {p}: {e}"))?; - writeln!(out, "wrote {p}").ok(); + let text = std::fs::read_to_string(p) + .map_err(|e| format!("cannot read --wrappers {p}: {e}"))?; + source::routes::wrappers::parse(&text).map_err(|e| format!("--wrappers {p}: {e}"))? } - None => { - write!(out, "{text}").ok(); + }; + let service = match a.get("service") { + Some(s) if s.is_empty() || s.chars().any(char::is_control) => { + return Err("--service must be a non-empty name without control characters".into()) } - } - Ok(0) + other => other.map(str::to_string), + }; + let dir = PathBuf::from(a.get("dir").unwrap_or(".")); + let opts = source::routes::client::ClientOptions { wrappers, service }; + let doc = source::routes::client::client_routes(&dir, VERSION, &opts)?; + Ok(export::to_json(&doc)) } fn cmd_graph(a: &Args, out: &mut dyn Write) -> Result { diff --git a/src/cli_args.rs b/src/cli_args.rs index b8c5286..e163fe4 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -83,6 +83,8 @@ const VALUE_FLAGS: &[&str] = &[ "generated-at", "role", "framework", + "wrappers", + "service", ]; const BOOL_FLAGS: &[&str] = &[ "deps", diff --git a/src/source/routes.rs b/src/source/routes.rs index d2498ba..d5d2aae 100644 --- a/src/source/routes.rs +++ b/src/source/routes.rs @@ -1,5 +1,6 @@ //! isthmus `bridge-facts` v1 http 도메인의 서버 측 생산자(`rustograph routes //! --role server`) — axum·actix-web 라우트 선언을 `route-decl` 사실로 낸다. +//! 호출 측(`--role client`, reqwest·ureq `route-call`)은 [`client`]에 있다. //! //! 계약의 정본은 ../isthmus의 docs/GRAPH-EXCHANGE.md "HTTP 경계"다. 프레임워크 //! 규칙과 공식 소스 근거는 docs/HTTP-ROUTES.md에 있다. 흐름: @@ -14,10 +15,13 @@ mod actix; mod axum; +pub mod client; mod common; +pub mod compose; mod pattern; pub mod template; pub mod validate; +pub mod wrappers; use crate::cargo_meta; use crate::source::schema::{rfc3339_utc_now, BridgeFactsTool, BridgeLocation, FactSymbol}; diff --git a/src/source/routes/client.rs b/src/source/routes/client.rs new file mode 100644 index 0000000..5e8fe43 --- /dev/null +++ b/src/source/routes/client.rs @@ -0,0 +1,429 @@ +//! isthmus `bridge-facts` v1 http 도메인의 호출 측 생산자(`rustograph routes --role +//! client`) — reqwest·ureq 요청과 선언된 래퍼 호출을 `route-call` 사실로 낸다. +//! +//! 흐름: +//! +//! 1. `cargo metadata`로 멤버 크레이트별 reqwest·ureq(과 모델링하지 않는 클라이언트) +//! 의존과 버전을 찾는다 — ureq는 메이저에 따라 URL 해석기가 다르다. +//! 2. `impact`와 같은 syn 수확으로 모듈 트리·AST·정점 집합을 얻는다 — `symbol.usr`가 +//! `reach`/`impact`의 정점 ID와 같아야 isthmus `trace`가 호출부에서 이어 간다. +//! 3. 스캐너가 두 번 돈다(필드 값 수집 → 호출 사실). URL 조립은 `compose`가, +//! 래퍼 동사 바인딩은 `wrappers`가 한다. +//! 4. 사실을 정렬·검사하고 호출 측 공백을 닫힌 접두사 목록의 한계로 낸다. + +mod index; +mod scan; + +use super::common::Ctx; +use super::compose::{Outcome, PathAnchor}; +use super::template::template_problem; +use super::wrappers::Wrapper; +use crate::cargo_meta; +use crate::source::schema::{rfc3339_utc_now, BridgeFactsTool, BridgeLocation, FactSymbol}; +use index::Index; +use scan::{scan_crate, CallSite, Collected, Libs, Mode, Shared}; +use serde::Serialize; +use std::collections::BTreeSet; +use std::path::Path; + +/// 이름으로 사용을 세는, 모델링하지 않는 HTTP 클라이언트 패키지(cargo_meta가 `-`를 +/// `_`로 정규화한 이름). +const UNMODELLED: &[&str] = &[ + "hyper", + "hyper_util", + "surf", + "awc", + "isahc", + "attohttpc", + "minreq", + "curl", + "ehttp", + "gloo_net", + "reqwasm", + "http_client", + "reqwest_middleware", +]; + +/// `routes --role client` 옵션. +#[derive(Debug, Default)] +pub struct ClientOptions { + /// `http-wrappers` v1 선언(모든 언어 항목 — `rust`만 적용한다). + pub wrappers: Vec, + /// 문서 수준 `service`. + pub service: Option, +} + +/// route-call 사실 하나. 키 순서는 서버 사실과 같은 계약 나열 순서다. +#[derive(Serialize)] +pub struct CallFact { + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(rename = "methodDynamic", skip_serializing_if = "Option::is_none")] + pub method_dynamic: Option, + /// dynamic이면 null — 원문 식은 URL의 userinfo·query를 담을 수 있어 싣지 않는다. + pub channel: Option, + pub dynamic: bool, + #[serde(rename = "pathAnchor")] + pub path_anchor: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub authority: Option, + #[serde(rename = "baseRef", skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(rename = "queryTailStripped", skip_serializing_if = "Option::is_none")] + pub query_tail_stripped: Option, + #[serde(rename = "channelPrefix", skip_serializing_if = "Option::is_none")] + pub channel_prefix: Option, + #[serde(rename = "maskedSegments", skip_serializing_if = "Option::is_none")] + pub masked_segments: Option, + pub location: BridgeLocation, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, +} + +/// 문서의 `sourceSets`. +#[derive(Serialize, Debug)] +pub struct ClientSourceSets { + pub tests: &'static str, +} + +/// isthmus bridge-facts v1 http 클라이언트 문서다. +#[derive(Serialize)] +pub struct ClientDocument { + 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`다(스캔했으나 호출 없음). + pub target: &'static str, + pub project: String, + pub roles: Vec<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(rename = "sourceSets")] + pub source_sets: ClientSourceSets, + pub facts: Vec, + pub limitations: Vec, +} + +/// 멤버 크레이트 하나 — 수확 루트와 의존 라이브러리. +struct Member { + name: String, + roots: Vec, + libs: Libs, + /// (패키지, 버전) — 확인한 버전 밖이면 한계로 센다. + versions: Vec<(String, String)>, +} + +/// 워크스페이스의 HTTP 요청 호출을 http 클라이언트 문서로 낸다. +pub fn client_routes( + dir: &Path, + tool_version: &str, + opts: &ClientOptions, +) -> Result { + let root = dir + .canonicalize() + .map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?; + let meta = cargo_meta::load(dir)?; + let members = members(&meta); + let parts = crate::source::harvest_parts(dir, &meta)?; + let ctx = Ctx::new(&parts, root.clone()); + let krates: Vec = members.iter().flat_map(|m| m.roots.clone()).collect(); + let index = Index::build(&ctx, &krates); + let wrappers: Vec = opts + .wrappers + .iter() + .filter(|w| w.language == "rust") + .cloned() + .collect(); + check_services(&wrappers, opts.service.as_deref())?; + let sh = Shared::new(&ctx, &index, &wrappers); + for mode in [Mode::Collect, Mode::Emit] { + for m in &members { + for r in &m.roots { + scan_crate(&sh, r, &m.libs, mode); + } + } + if mode == Mode::Collect { + sh.finish_collect(); + } + } + let collected = std::mem::take(&mut *sh.out.borrow_mut()); + let mut limitations = coverage(&members, &collected); + limitations.extend(wrapper_gaps(&sh, &wrappers, &collected)); + let (facts, missing) = facts(&ctx, &collected.calls, &mut limitations)?; + if missing > 0 { + limitations.push(format!("missing-route-usrs: {missing} route call(s) have no enclosing workspace function or method; they carry no symbol")); + } + for l in &meta.limitations { + limitations.push(format!("route-call-coverage: {l}")); + } + limitations.sort(); + limitations.dedup(); + Ok(ClientDocument { + 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!["client"], + service: opts.service.clone(), + source_sets: ClientSourceSets { tests: "excluded" }, + facts, + limitations, + }) +} + +/// 래퍼 선언의 service가 문서 service와 다르면 isthmus가 문서를 거부한다 — 먼저 막는다. +fn check_services(wrappers: &[Wrapper], service: Option<&str>) -> Result<(), String> { + let Some(doc) = service else { return Ok(()) }; + for w in wrappers { + if let Some(s) = &w.service { + if s != doc { + return Err(format!( + "wrappers[{}] declares service {s:?} but --service is {doc:?}; isthmus rejects a fact whose service differs from the document's — drop --service or align the declaration", + w.position + )); + } + } + } + Ok(()) +} + +/// 워크스페이스 멤버와 그 HTTP 클라이언트 의존. +fn members(meta: &cargo_meta::Metadata) -> Vec { + let mut out = Vec::new(); + for (id, &pi) in &meta.by_id { + let pkg = &meta.packages[pi]; + if !pkg.workspace_member { + continue; + } + let roots: Vec = pkg + .targets + .iter() + .filter(|t| matches!(t.kind.as_str(), "lib" | "bin")) + .map(|t| t.name.clone()) + .collect::>() + .into_iter() + .collect(); + let mut libs = Libs::default(); + let mut versions = Vec::new(); + for d in meta + .dep_edges + .iter() + .filter(|d| &d.from == id && d.kind.is_empty()) + { + let Some(&ti) = meta.by_id.get(&d.to) else { + continue; + }; + let to = &meta.packages[ti]; + match to.name.as_str() { + "reqwest" => { + libs.reqwest = Some(d.lib_name.clone()); + versions.push((to.name.clone(), to.version.clone())); + } + "ureq" => { + // 메이저를 읽지 못하면 최신(3.x, http::Uri) 규칙으로 읽고 버전 한계로 센다. + let major = to + .version + .split('.') + .next() + .and_then(|m| m.parse().ok()) + .unwrap_or(3); + libs.ureq = Some((d.lib_name.clone(), major)); + versions.push((to.name.clone(), to.version.clone())); + } + name if UNMODELLED.contains(&name) => libs.unmodelled.push(d.lib_name.clone()), + _ => {} + } + } + out.push(Member { + name: pkg.name.clone(), + roots, + libs, + versions, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// 호출 측 커버리지 한계(`route-call-coverage:`). +fn coverage(members: &[Member], c: &Collected) -> Vec { + let mut out = Vec::new(); + for m in members { + for (name, version) in &m.versions { + let verified = match name.as_str() { + "reqwest" => version.starts_with("0.13.") || version.starts_with("0.12."), + _ => version.starts_with("3."), + }; + if !verified { + out.push(format!( + "route-call-coverage: {} depends on {name} {version}, outside the versions checked against a live server (reqwest 0.12/0.13, ureq 3); its URLs were read with the same rules", + m.name + )); + } + } + } + for (lib, n) in &c.unmodelled { + out.push(format!( + "route-call-coverage: {n} use(s) of {lib}, an HTTP client rustograph does not model; their requests are not reported" + )); + } + if c.unproven > 0 { + out.push(format!( + "route-call-coverage: {} request-shaped call(s) are sent from a receiver that is not a proven reqwest::Client or ureq::Agent; they are not reported", + c.unproven + )); + } + if c.unrequestable > 0 { + out.push(format!( + "route-call-coverage: {} call(s) pass a relative URL with no base or a non-http(s) URL, which the client rejects before sending; they are not reported", + c.unrequestable + )); + } + let ambiguous = c + .calls + .iter() + .filter(|s| { + matches!( + s.outcome, + Outcome::Dynamic { + ambiguous: true, + .. + } + ) + }) + .count(); + if ambiguous > 0 { + out.push(format!("ambiguous-base-join: {ambiguous} call(s) glue a relative path to a base URL whose path is unknown; they are dynamic")); + } + if c.undeclared > 0 { + out.push(format!("http-wrapper-undeclared: {} dynamic call(s) build their URL or verb from a parameter of an undeclared function; declare it in an http-wrappers file to resolve its callers", c.undeclared)); + } + out +} + +/// 선언된 래퍼의 공백 — 정점에 닿지 않거나 호출이 0건. +fn wrapper_gaps(sh: &Shared, wrappers: &[Wrapper], c: &Collected) -> Vec { + let mut out = Vec::new(); + for (i, w) in wrappers.iter().enumerate() { + let calls = c.wrapper_calls.get(&i).copied().unwrap_or(0); + let target = format!("{}::{}", w.owner, w.name); + if !sh.wrapper_resolved(i) { + out.push(format!("http-wrapper-unresolved: wrappers[{}] ({target}) does not name a rustograph function, method or struct", w.position)); + } else if calls == 0 { + out.push(format!( + "http-wrapper-unresolved: wrappers[{}] ({target}) has no calls", + w.position + )); + } + } + out +} + +/// 호출을 사실로 바꾸고 정렬·중복 제거·문법 검사를 한다. (사실, usr 없는 수). +fn facts( + ctx: &Ctx, + calls: &[CallSite], + limitations: &mut Vec, +) -> Result<(Vec, usize), String> { + let mut out = Vec::new(); + let mut missing = 0; + let mut unlocated = 0; + for c in calls { + let Some(location) = ctx.locate_utf8(&c.loc) else { + unlocated += 1; + continue; + }; + let symbol = ctx.owner_of(&c.loc).map(|id| FactSymbol { + qualified_name: id.clone(), + usr: id, + }); + if symbol.is_none() { + missing += 1; + } + out.push(to_fact(c, location, symbol)?); + } + if unlocated > 0 { + limitations.push(format!( + "route-call-coverage: {unlocated} route call(s) had no source location and were not emitted" + )); + } + out.sort_by_key(fact_key); + out.dedup_by(|a, b| fact_key(a) == fact_key(b)); + Ok((out, missing)) +} + +/// 호출 하나를 계약 사실로 쓴다. 정적 템플릿이 문법을 어기면 생산자 결함이다. +fn to_fact( + c: &CallSite, + location: BridgeLocation, + symbol: Option, +) -> Result { + let mut fact = CallFact { + kind: "route-call", + method: c.method.clone(), + method_dynamic: c.method.is_none().then_some(true), + channel: None, + dynamic: true, + path_anchor: PathAnchor::Base.as_str(), + authority: None, + base_ref: c.base_ref.clone(), + service: c.service.clone(), + query_tail_stripped: None, + channel_prefix: None, + masked_segments: None, + location, + symbol, + }; + match &c.outcome { + Outcome::Template(t) => { + if let Some(problem) = template_problem(&t.template) { + return Err(format!("internal error: route-call template {:?} is not canonical ({problem}); please report this with the call source", t.template)); + } + fact.channel = Some(t.template.clone()); + fact.dynamic = false; + fact.path_anchor = t.anchor.as_str(); + fact.authority = t.authority.clone(); + fact.query_tail_stripped = t.query_tail_stripped.then_some(true); + fact.masked_segments = (t.masked_segments > 0).then_some(t.masked_segments); + } + Outcome::Dynamic { + prefix, + anchor, + masked_segments, + .. + } => { + fact.path_anchor = anchor.as_str(); + fact.channel_prefix = prefix.clone(); + fact.masked_segments = + (*masked_segments > 0 && prefix.is_some()).then_some(*masked_segments); + } + Outcome::Unrequestable => { + return Err("internal error: an unrequestable call reached fact assembly".into()) + } + } + Ok(fact) +} + +/// 사실의 결정적 정렬 키. +fn fact_key(f: &CallFact) -> (String, u32, u32, Option, Option, bool) { + ( + f.location.path.clone(), + f.location.line, + f.location.column, + f.method.clone(), + f.channel.clone(), + f.dynamic, + ) +} diff --git a/src/source/routes/client/index.rs b/src/source/routes/client/index.rs new file mode 100644 index 0000000..822e34e --- /dev/null +++ b/src/source/routes/client/index.rs @@ -0,0 +1,316 @@ +//! 클라이언트 추출기의 워크스페이스 색인 — 구조체 필드 타입, impl 메서드, 자유 +//! 함수, 문자열 상수·static, 모듈별 `use` 표. +//! +//! 타입은 경로 문자열로 다룬다. 워크스페이스 아이템은 rustograph 정점 ID +//! (`app::api::ApiClient`), 외부 타입은 `use`를 펼친 경로(`reqwest::Client`)다. +//! 제네릭은 버리고, 역참조로 같은 메서드를 부르는 포장(`&`·`Arc`·`Rc`·`Box`· +//! `Lazy`·`LazyLock`·`OnceLock`·`Result`)은 벗긴다 — 수신자가 reqwest 클라이언트인지 +//! 가리는 데 필요한 만큼만 본다. + +use super::super::common::{is_test_item, Ctx, Imports}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// 역참조·언랩으로 안쪽 타입의 메서드를 그대로 부르는 포장 타입 이름. +const TRANSPARENT: &[&str] = &[ + "Arc", "Rc", "Box", "Lazy", "LazyLock", "LazyCell", "OnceLock", "OnceCell", "Result", "Cow", +]; + +/// 함수 하나의 서명과 위치. +pub(super) struct FnInfo { + pub module: String, + pub sig: &'static syn::Signature, + /// impl 메서드면 그 self 타입 ID. + pub self_ty: Option, +} + +/// 문자열 상수·static 하나. +pub(super) struct ConstInfo { + pub module: String, + pub expr: &'static syn::Expr, + pub ty: &'static syn::Type, + pub self_ty: Option, +} + +/// 구조체 하나 — 이름 붙은 필드의 타입과 선언 모듈. +pub(super) struct StructInfo { + pub module: String, + pub fields: BTreeMap, + /// `#[derive(Default)]` — 모든 필드가 기본값(빈 문자열)인 생성 경로가 있다. + pub derives_default: bool, +} + +/// 워크스페이스 색인. +pub(super) struct Index { + pub structs: BTreeMap, + /// (self 타입 ID, 메서드 이름) → 서명들(고유 impl과 트레이트 impl). + pub methods: BTreeMap<(String, String), Vec>, + pub fns: BTreeMap, + pub consts: BTreeMap, + /// 스캔하는 멤버 크레이트 루트 이름. + pub roots: std::collections::BTreeSet, + imports: RefCell>>, +} + +impl Index { + /// 크레이트 루트들의 테스트가 아닌 모듈을 훑어 색인을 만든다. + pub fn build(ctx: &Ctx, krates: &[String]) -> Index { + let mut index = Index { + structs: BTreeMap::new(), + methods: BTreeMap::new(), + fns: BTreeMap::new(), + consts: BTreeMap::new(), + roots: krates.iter().cloned().collect(), + imports: RefCell::new(BTreeMap::new()), + }; + for krate in krates { + for module in ctx.crate_modules(krate) { + for (_, items) in ctx.parts.module_items(&module) { + for item in items { + index.add_item(ctx, &module, item); + } + } + } + } + index + } + + /// 모듈 수준 아이템 하나를 색인에 넣는다. + fn add_item(&mut self, ctx: &Ctx, module: &str, item: &'static syn::Item) { + match item { + syn::Item::Struct(s) if !is_test_item(&s.attrs) => { + let fields = match &s.fields { + syn::Fields::Named(n) => n + .named + .iter() + .filter_map(|f| Some((f.ident.as_ref()?.to_string(), &f.ty))) + .collect(), + _ => BTreeMap::new(), + }; + self.structs.insert( + format!("{module}::{}", s.ident), + StructInfo { + module: module.to_string(), + fields, + derives_default: derives_default(&s.attrs), + }, + ); + } + syn::Item::Fn(f) if !is_test_item(&f.attrs) => { + self.fns.insert( + format!("{module}::{}", f.sig.ident), + FnInfo { + module: module.to_string(), + sig: &f.sig, + self_ty: None, + }, + ); + } + syn::Item::Const(c) => self.insert_const( + format!("{module}::{}", c.ident), + module, + &c.expr, + &c.ty, + None, + ), + syn::Item::Static(s) => self.insert_const( + format!("{module}::{}", s.ident), + module, + &s.expr, + &s.ty, + None, + ), + syn::Item::Impl(i) if !is_test_item(&i.attrs) => self.add_impl(ctx, module, i), + _ => {} + } + } + + /// impl 블록의 메서드와 연관 상수를 넣는다. + fn add_impl(&mut self, ctx: &Ctx, module: &str, i: &'static syn::ItemImpl) { + let Some(self_ty) = self.resolve_type(ctx, module, None, &i.self_ty) else { + return; + }; + for item in &i.items { + match item { + syn::ImplItem::Fn(f) if !is_test_item(&f.attrs) => { + self.methods + .entry((self_ty.clone(), f.sig.ident.to_string())) + .or_default() + .push(FnInfo { + module: module.to_string(), + sig: &f.sig, + self_ty: Some(self_ty.clone()), + }); + } + syn::ImplItem::Const(c) => self.insert_const( + format!("{self_ty}::{}", c.ident), + module, + &c.expr, + &c.ty, + Some(self_ty.clone()), + ), + _ => {} + } + } + } + + fn insert_const( + &mut self, + id: String, + module: &str, + expr: &'static syn::Expr, + ty: &'static syn::Type, + self_ty: Option, + ) { + self.consts.insert( + id, + ConstInfo { + module: module.to_string(), + expr, + ty, + self_ty, + }, + ); + } + + /// 모듈의 `use` 표(캐시). + pub fn imports(&self, ctx: &Ctx, module: &str) -> std::rc::Rc { + if let Some(i) = self.imports.borrow().get(module) { + return i.clone(); + } + let groups = ctx.parts.module_items(module); + let imports = std::rc::Rc::new(Imports::of(&groups)); + self.imports + .borrow_mut() + .insert(module.to_string(), imports.clone()); + imports + } + + /// 경로를 타입 문자열로 — 워크스페이스 아이템이면 정점 ID, 아니면 `use`를 펼친 + /// 외부 경로. `Self`는 self 타입이다. + pub fn resolve_path( + &self, + ctx: &Ctx, + module: &str, + self_ty: Option<&str>, + segs: &[String], + ) -> Option { + let (first, rest) = segs.split_first()?; + if first == "Self" { + let mut out = self_ty?.to_string(); + for s in rest { + out.push_str("::"); + out.push_str(s); + } + return Some(out); + } + let dep = crate::modtree::DepCrates::new(); + // 가장 긴 워크스페이스 접두사를 찾는다 — `ApiClient::new`는 타입까지만 모듈 + // 트리에 있고 연관 함수 이름은 그 뒤에 붙는다. + for cut in (1..=segs.len()).rev() { + if let Some(id) = workspace_resolve(ctx, self, module, &segs[..cut], &dep) { + let mut out = id; + for s in &segs[cut..] { + out.push_str("::"); + out.push_str(s); + } + return Some(out); + } + } + let full = self.imports(ctx, module).expand(segs); + (!full.is_empty()).then(|| full.join("::")) + } + + /// 타입 식을 타입 문자열로 — 참조·괄호·포장 타입을 벗긴다. + pub fn resolve_type( + &self, + ctx: &Ctx, + module: &str, + self_ty: Option<&str>, + ty: &syn::Type, + ) -> Option { + match ty { + syn::Type::Reference(r) => self.resolve_type(ctx, module, self_ty, &r.elem), + syn::Type::Paren(p) => self.resolve_type(ctx, module, self_ty, &p.elem), + syn::Type::Group(g) => self.resolve_type(ctx, module, self_ty, &g.elem), + syn::Type::Path(p) => { + let last = p.path.segments.last()?; + if TRANSPARENT.contains(&last.ident.to_string().as_str()) { + if let syn::PathArguments::AngleBracketed(a) = &last.arguments { + let inner = a.args.iter().find_map(|g| match g { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + })?; + return self.resolve_type(ctx, module, self_ty, inner); + } + } + let segs = crate::harvest::path_segments(&p.path); + self.resolve_path(ctx, module, self_ty, &segs) + } + _ => None, + } + } + + /// 서명의 반환 타입. + pub fn return_type(&self, ctx: &Ctx, f: &FnInfo) -> Option { + match &f.sig.output { + syn::ReturnType::Type(_, ty) => { + self.resolve_type(ctx, &f.module, f.self_ty.as_deref(), ty) + } + syn::ReturnType::Default => None, + } + } + + /// 구조체 필드의 타입. + pub fn field_type(&self, ctx: &Ctx, owner: &str, field: &str) -> Option { + let s = self.structs.get(owner)?; + let ty = s.fields.get(field)?; + self.resolve_type(ctx, &s.module, Some(owner), ty) + } +} + +/// 경로를 모듈 트리로 풀되 스캔하는 멤버 크레이트 안의 ID만 받는다 — 트리는 외부 +/// 크레이트 이름(`reqwest`)도 크레이트 정점으로 풀고, 워크스페이스 밖 path 의존도 +/// 모듈로 담을 수 있다. +pub(super) fn workspace_resolve( + ctx: &Ctx, + index: &Index, + module: &str, + segs: &[String], + dep: &crate::modtree::DepCrates, +) -> Option { + let id = ctx.parts.tree.resolve(module, segs, dep)?; + index + .roots + .contains(&crate::modtree::crate_of(&id)) + .then_some(id) +} + +/// `#[derive(.., Default, ..)]`인가. +fn derives_default(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|a| { + a.path().is_ident("derive") + && a.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) + .is_ok_and(|list| { + list.iter() + .any(|p| p.segments.last().is_some_and(|s| s.ident == "Default")) + }) + }) +} + +/// 모듈 파일 목록(테스트 모듈 제외) — 스캐너가 아이템을 훑는 단위다. +pub(super) fn module_groups( + ctx: &Ctx, + krate: &str, +) -> Vec<(String, PathBuf, &'static [syn::Item])> { + let mut out = Vec::new(); + for module in ctx.crate_modules(krate) { + for (file, items) in ctx.parts.module_items(&module) { + out.push((module.clone(), file, items)); + } + } + out +} diff --git a/src/source/routes/client/scan.rs b/src/source/routes/client/scan.rs new file mode 100644 index 0000000..9c52674 --- /dev/null +++ b/src/source/routes/client/scan.rs @@ -0,0 +1,1827 @@ +//! 함수 본문 스캐너 — 문자열·URL 값과 수신자 타입을 따라가며 HTTP 요청 호출을 찾는다. +//! +//! 두 번 돈다. `Collect`는 구조체 리터럴의 필드 값(생성자에서 base URL을 리터럴· +//! 상수로 채우는지)을 모으고, `Emit`은 그 표로 `self.base_url` 같은 필드를 풀어 +//! 호출 사실을 낸다. 해석은 구문 수준이다 — 증명하지 못한 값은 [`Origin`]을 단 +//! 값 조각으로 남고, 조립 규칙(`compose`)이 dynamic으로 판정한다. + +use super::super::common::{is_test_item, Ctx, Imports, Loc}; +use super::super::compose::{Origin, Outcome, PathAnchor, Piece, UrlVal}; +use super::super::wrappers::{self, ArgValue, CallArg, Wrapper, WrapperKind}; +use super::index::{module_groups, workspace_resolve, Index}; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::rc::Rc; +use syn::spanned::Spanned; +use syn::visit::Visit; + +/// 계약 동사. +const VERBS: &[&str] = wrappers::VERBS; + +/// RequestBuilder에서 요청을 보내는 메서드 — 수신자를 증명하지 못한 요청 모양 호출을 센다. +const SEND_METHODS: &[&str] = &[ + "send", + "call", + "send_string", + "send_json", + "send_form", + "send_bytes", + "send_empty", +]; + +/// 요청 빌더를 이어 가는 메서드 — 보내기 호출에서 동사 호출까지 거슬러 오를 때 건넌다. +const BUILDER_METHODS: &[&str] = &[ + "header", + "headers", + "query", + "json", + "form", + "body", + "timeout", + "basic_auth", + "bearer_auth", + "multipart", + "version", + "set", + "config", + "content_type", +]; + +/// 값을 제자리에서 바꾸는 메서드 — 그 지역 변수·필드의 값은 믿지 않는다. +const MUTATORS: &[&str] = &[ + "push_str", + "push", + "insert", + "insert_str", + "clear", + "truncate", + "extend", + "retain", + "drain", + "remove", + "replace_range", + "set_path", + "set_query", + "set_fragment", + "set_host", + "set_port", + "set_scheme", + "set_username", + "set_password", + "path_segments_mut", + "query_pairs_mut", + "make_ascii_lowercase", + "make_ascii_uppercase", + "as_mut_str", +]; + +/// 값을 그대로 넘기는 메서드(문자열·URL·Result). +const PASS_THROUGH: &[&str] = &[ + "to_string", + "to_owned", + "into", + "as_str", + "as_ref", + "clone", + "borrow", + "as_deref", + "into_owned", + "unwrap", + "expect", +]; + +/// 스캔 단계. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Mode { + Collect, + Emit, +} + +/// 크레이트 하나가 직접 의존하는 HTTP 클라이언트 라이브러리(lib 이름). +#[derive(Clone, Debug, Default)] +pub(super) struct Libs { + pub reqwest: Option, + /// (lib 이름, 메이저 버전) — 2.x는 `url::Url`, 3.x는 `http::Uri`로 해석한다. + pub ureq: Option<(String, u64)>, + /// 모델링하지 않는 클라이언트 크레이트의 lib 이름. + pub unmodelled: Vec, +} + +/// 요청 라이브러리 종류. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Client { + Reqwest, + Ureq, +} + +/// 식을 평가한 값. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Val { + /// 문자열 조각과 앞머리의 `baseRef` 후보(필드·상수 id). + Str(Vec, Option), + /// `url::Url` 값. + Url(UrlVal, Option), + /// 문자열·URL로 모르는 값. + Other(Origin), +} + +impl Val { + /// 문자열 문맥의 조각. + fn pieces(&self) -> Vec { + match self { + Val::Str(p, _) => p.clone(), + Val::Url(u, _) => u.to_pieces(), + Val::Other(o) => vec![Piece::Value(o.clone())], + } + } + + /// 앞머리 값의 `baseRef`. + fn base_ref(&self) -> Option { + match self { + Val::Str(_, r) | Val::Url(_, r) => r.clone(), + Val::Other(Origin::Base(id)) => Some(id.clone()), + Val::Other(_) => None, + } + } + + /// `baseRef`를 바꾼 값. + fn with_ref(self, r: Option) -> Val { + match self { + Val::Str(p, _) => Val::Str(p, r), + Val::Url(u, _) => Val::Url(u, r), + other => other, + } + } + + fn unknown() -> Val { + Val::Other(Origin::Unknown) + } +} + +/// 찾은 호출 하나. +#[derive(Clone, Debug)] +pub(super) struct CallSite { + pub loc: Loc, + /// None이면 `methodDynamic`. + pub method: Option, + pub outcome: Outcome, + pub base_ref: Option, + pub service: Option, +} + +/// 스캔 결과와 계수. +#[derive(Default)] +pub(super) struct Collected { + pub calls: Vec, + /// 수신자를 reqwest·ureq 클라이언트로 증명하지 못한 요청 모양 호출. + pub unproven: usize, + /// base 없는 상대 URL이라 요청이 되지 않는 호출. + pub unrequestable: usize, + /// 모델링하지 않는 클라이언트 lib 이름 → 사용 위치 수. + pub unmodelled: BTreeMap, + /// 선언 위치 → 찾은 호출 수. + pub wrapper_calls: BTreeMap, + /// 매개변수를 URL·동사로 흘려보내는 선언되지 않은 래퍼 싱크. + pub undeclared: usize, +} + +/// 필드 값 누적 — 모든 생성 위치가 같은 값이어야 확정이다. +#[derive(Default)] +struct FieldAcc { + value: Option, + conflict: bool, +} + +/// 스캔 전체가 공유하는 상태. +pub(super) struct Shared<'a> { + pub ctx: &'a Ctx<'a>, + pub index: &'a Index, + pub wrappers: &'a [Wrapper], + /// 함수·연관 함수 래퍼: 정점 ID → 선언 순번. + fn_wrappers: BTreeMap, + /// 구조체 리터럴·튜플 생성 래퍼: 구조체 ID → 선언 순번. + ctor_wrappers: BTreeMap, + fields: RefCell>, + /// 확정한 필드 값(`Emit` 단계). + resolved: RefCell>, + /// 어디선가 제자리 수정되는 필드 — (소유 구조체, 이름). 소유 타입을 모르면 None이라 + /// 그 이름의 모든 필드를 믿지 않는다. + poisoned: RefCell, String)>>, + consts: RefCell>>, + pub out: RefCell, +} + +impl<'a> Shared<'a> { + /// 선언 순번 표를 만든다. `rust` 선언 중 정점에 닿는 것만 매칭 대상이다. + pub fn new(ctx: &'a Ctx<'a>, index: &'a Index, wrappers: &'a [Wrapper]) -> Shared<'a> { + let mut fn_wrappers = BTreeMap::new(); + let mut ctor_wrappers = BTreeMap::new(); + for (i, w) in wrappers.iter().enumerate() { + let type_name = w.owner.rsplit("::").next().unwrap_or(&w.owner); + if w.kind == WrapperKind::Constructor && w.name == type_name { + if index.structs.contains_key(&w.owner) { + ctor_wrappers.insert(w.owner.clone(), i); + } + continue; + } + let id = format!("{}::{}", w.owner, w.name); + if ctx.ids.contains(id.as_str()) { + fn_wrappers.insert(id, i); + } + } + let mut fields: BTreeMap<(String, String), FieldAcc> = BTreeMap::new(); + for (id, s) in &index.structs { + if s.derives_default { + for f in s.fields.keys() { + fields.entry((id.clone(), f.clone())).or_default().conflict = true; + } + } + } + Shared { + ctx, + index, + wrappers, + fn_wrappers, + ctor_wrappers, + fields: RefCell::new(fields), + resolved: RefCell::new(BTreeMap::new()), + poisoned: RefCell::new(BTreeSet::new()), + consts: RefCell::new(BTreeMap::new()), + out: RefCell::new(Collected::default()), + } + } + + /// 선언이 정점·구조체에 닿았는가. + pub fn wrapper_resolved(&self, i: usize) -> bool { + self.fn_wrappers.values().any(|&j| j == i) || self.ctor_wrappers.values().any(|&j| j == i) + } + + /// `Collect` 결과를 확정 표로 바꾼다. + pub fn finish_collect(&self) { + let poisoned = self.poisoned.borrow(); + let mut resolved = self.resolved.borrow_mut(); + for ((s, f), acc) in self.fields.borrow().iter() { + let hit = poisoned.contains(&(None, f.clone())) + || poisoned.contains(&(Some(s.clone()), f.clone())); + if acc.conflict || hit { + continue; + } + if let Some(v) = &acc.value { + if is_constant(v) { + resolved.insert((s.clone(), f.clone()), v.clone()); + } + } + } + } + + /// 필드 값 하나를 누적한다(생성 위치마다). + fn add_field(&self, owner: &str, field: &str, v: Val) { + let v = v.with_ref(None); + let mut fields = self.fields.borrow_mut(); + let acc = fields + .entry((owner.to_string(), field.to_string())) + .or_default(); + match &acc.value { + None => acc.value = Some(v), + Some(prev) if *prev == v => {} + Some(_) => acc.conflict = true, + } + } +} + +/// 크레이트 하나를 스캔한다. +pub(super) fn scan_crate(sh: &Shared, krate: &str, libs: &Libs, mode: Mode) { + for (module, file, items) in module_groups(sh.ctx, krate) { + let imports = sh.index.imports(sh.ctx, &module); + let mut s = Scanner { + sh, + mode, + module, + file, + imports, + libs, + self_ty: None, + env: Env::default(), + mutated: BTreeSet::new(), + in_wrapper: false, + }; + for item in items { + s.scan_item(item); + } + } +} + +/// 이름 하나의 바인딩. +#[derive(Clone, Debug)] +struct Binding { + val: Val, + ty: Option, +} + +/// 어휘 스코프 스택. +#[derive(Default)] +struct Env { + scopes: Vec>, +} + +impl Env { + fn push(&mut self) { + self.scopes.push(BTreeMap::new()); + } + + fn pop(&mut self) { + self.scopes.pop(); + } + + fn bind(&mut self, name: String, b: Binding) { + if self.scopes.is_empty() { + self.push(); + } + if let Some(top) = self.scopes.last_mut() { + top.insert(name, b); + } + } + + fn get(&self, name: &str) -> Option<&Binding> { + self.scopes.iter().rev().find_map(|s| s.get(name)) + } +} + +/// 모듈 파일 하나를 훑는 방문자. +struct Scanner<'s, 'a> { + sh: &'s Shared<'a>, + mode: Mode, + module: String, + file: PathBuf, + imports: Rc, + libs: &'s Libs, + self_ty: Option, + env: Env, + /// 지금 함수에서 제자리 수정되는 지역 변수 이름. + mutated: BTreeSet, + /// 지금 함수가 선언된 래퍼 본문인가 — 그 안의 dynamic 호출은 래퍼 호출 사실이 대신한다. + in_wrapper: bool, +} + +impl Scanner<'_, '_> { + /// 모듈 수준 아이템 하나. + fn scan_item(&mut self, item: &syn::Item) { + match item { + syn::Item::Fn(f) if !is_test_item(&f.attrs) => { + self.scan_fn(None, &f.sig, &f.block); + } + syn::Item::Impl(i) if !is_test_item(&i.attrs) => self.scan_impl(i), + syn::Item::Trait(t) if !is_test_item(&t.attrs) => { + for ti in &t.items { + if let syn::TraitItem::Fn(f) = ti { + if let Some(body) = &f.default { + self.scan_fn(None, &f.sig, body); + } + } + } + } + syn::Item::Const(c) => self.scan_detached(&c.expr), + syn::Item::Static(s) => self.scan_detached(&s.expr), + _ => {} + } + } + + /// impl 블록의 메서드들. + fn scan_impl(&mut self, i: &syn::ItemImpl) { + let self_ty = self + .sh + .index + .resolve_type(self.sh.ctx, &self.module, None, &i.self_ty); + for item in &i.items { + if let syn::ImplItem::Fn(f) = item { + if !is_test_item(&f.attrs) { + self.scan_fn(self_ty.clone(), &f.sig, &f.block); + } + } + } + } + + /// 함수 밖의 식(상수·static 초기식) — 빈 환경으로 본다. + fn scan_detached(&mut self, e: &syn::Expr) { + let saved = ( + std::mem::take(&mut self.env), + self.self_ty.take(), + self.in_wrapper, + ); + self.in_wrapper = false; + self.env.push(); + self.visit_expr(e); + (self.env, self.self_ty, self.in_wrapper) = saved; + } + + /// 함수 하나 — 매개변수를 바인딩하고 본문을 방문한다. 중첩 함수도 여기로 온다. + fn scan_fn(&mut self, self_ty: Option, sig: &syn::Signature, block: &syn::Block) { + let saved_env = std::mem::take(&mut self.env); + let saved_ty = std::mem::replace(&mut self.self_ty, self_ty); + let saved_mut = std::mem::replace(&mut self.mutated, mutations(block)); + let saved_wrapper = self.in_wrapper; + let own = Loc { + file: self.file.clone(), + span: sig.ident.span(), + }; + self.in_wrapper = self + .sh + .ctx + .owner_of(&own) + .is_some_and(|id| self.sh.fn_wrappers.contains_key(&id)); + self.env.push(); + for input in &sig.inputs { + if let syn::FnArg::Typed(t) = input { + let ty = self.resolve_type(&t.ty); + for name in pat_names(&t.pat) { + let b = Binding { + val: Val::Other(Origin::Param(name.clone())), + ty: ty.clone(), + }; + self.env.bind(name, b); + } + } + } + self.visit_block(block); + self.env = saved_env; + self.self_ty = saved_ty; + self.mutated = saved_mut; + self.in_wrapper = saved_wrapper; + } + + fn resolve_type(&self, ty: &syn::Type) -> Option { + self.sh + .index + .resolve_type(self.sh.ctx, &self.module, self.self_ty.as_deref(), ty) + } + + fn resolve_path(&self, segs: &[String]) -> Option { + self.sh + .index + .resolve_path(self.sh.ctx, &self.module, self.self_ty.as_deref(), segs) + } + + /// 경로를 `use`로 펼친다(외부 API 판정). + fn expand(&self, segs: &[String]) -> Vec { + self.imports.expand(segs) + } + + /// 경로가 워크스페이스 아이템을 가리키는가 — 같은 이름의 외부 API로 읽지 않기 위해서다. + fn is_workspace(&self, segs: &[String]) -> bool { + let dep = crate::modtree::DepCrates::new(); + segs.first().is_some_and(|f| f == "Self") + || (1..=segs.len()).any(|cut| { + workspace_resolve(self.sh.ctx, self.sh.index, &self.module, &segs[..cut], &dep) + .is_some() + }) + } + + // ── 타입 ───────────────────────────────────────────── + + /// 식의 타입(경로 문자열) — 수신자 판정에 필요한 만큼만 추론한다. + fn ty(&self, e: &syn::Expr) -> Option { + match e { + syn::Expr::Path(p) => { + let segs = crate::harvest::path_segments(&p.path); + if let [one] = segs.as_slice() { + if one == "self" { + return self.self_ty.clone(); + } + if let Some(b) = self.env.get(one) { + return b.ty.clone(); + } + } + let id = self.resolve_path(&segs)?; + let c = self.sh.index.consts.get(&id)?; + self.sh + .index + .resolve_type(self.sh.ctx, &c.module, c.self_ty.as_deref(), c.ty) + } + syn::Expr::Field(f) => { + let owner = self.ty(&f.base)?; + let syn::Member::Named(name) = &f.member else { + return None; + }; + self.sh + .index + .field_type(self.sh.ctx, &owner, &name.to_string()) + } + syn::Expr::Reference(r) => self.ty(&r.expr), + syn::Expr::Paren(p) => self.ty(&p.expr), + syn::Expr::Group(g) => self.ty(&g.expr), + syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Deref(_)) => self.ty(&u.expr), + syn::Expr::Try(t) => self.ty(&t.expr), + syn::Expr::Await(a) => self.ty(&a.base), + syn::Expr::Struct(s) => self.resolve_path(&crate::harvest::path_segments(&s.path)), + syn::Expr::Call(c) => self.call_ty(c), + syn::Expr::MethodCall(m) => self.method_ty(m), + _ => None, + } + } + + /// 호출식의 타입 — 알려진 생성자와 워크스페이스 함수의 반환 타입. + fn call_ty(&self, c: &syn::ExprCall) -> Option { + let syn::Expr::Path(p) = &*c.func else { + return None; + }; + let segs = crate::harvest::path_segments(&p.path); + if !self.is_workspace(&segs) { + let full = self.expand(&segs); + if let Some(t) = self.library_ctor(&full) { + return Some(t); + } + if matches!(full.as_slice(), [.., w, n] if n == "new" && matches!(w.as_str(), "Arc" | "Rc" | "Box")) + { + return c.args.first().and_then(|a| self.ty(a)); + } + } + let id = self.resolve_path(&segs)?; + if let Some(f) = self.sh.index.fns.get(&id) { + return self.sh.index.return_type(self.sh.ctx, f); + } + let (owner, name) = id.rsplit_once("::")?; + if let Some(fs) = self + .sh + .index + .methods + .get(&(owner.to_string(), name.to_string())) + { + return fs + .iter() + .find_map(|f| self.sh.index.return_type(self.sh.ctx, f)); + } + // 외부 타입의 `T::new()`·`T::default()`는 T다. + if matches!(name, "new" | "default") && !self.is_workspace(&segs) { + return Some(owner.to_string()); + } + None + } + + /// 라이브러리 생성자 경로의 타입(reqwest·ureq). + fn library_ctor(&self, full: &[String]) -> Option { + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + if let Some(lib) = &self.libs.reqwest { + let tail = match parts.split_first() { + Some((first, tail)) if first == lib => tail, + _ => &[][..], + }; + let t = match tail { + ["Client", "new" | "default"] => Some("Client"), + ["blocking", "Client", "new" | "default"] => Some("blocking::Client"), + ["Client", "builder"] | ["ClientBuilder", "new"] => Some("ClientBuilder"), + ["blocking", "Client", "builder"] | ["blocking", "ClientBuilder", "new"] => { + Some("blocking::ClientBuilder") + } + _ => None, + }; + if let Some(t) = t { + return Some(format!("{lib}::{t}")); + } + } + if let Some((lib, _)) = &self.libs.ureq { + let tail = match parts.split_first() { + Some((first, tail)) if first == lib => tail, + _ => &[][..], + }; + let t = match tail { + ["agent"] | ["Agent", "new" | "new_with_defaults" | "new_with_config"] => { + Some("Agent") + } + ["builder"] | ["AgentBuilder", "new"] => Some("AgentBuilder"), + ["Agent", "config_builder"] | ["config", "Config", "builder"] => { + Some("ConfigBuilder") + } + _ => None, + }; + if let Some(t) = t { + return Some(format!("{lib}::{t}")); + } + } + None + } + + /// 메서드 호출식의 타입. + fn method_ty(&self, m: &syn::ExprMethodCall) -> Option { + let name = m.method.to_string(); + let recv = self.ty(&m.receiver); + let pass = PASS_THROUGH.contains(&name.as_str()) + || matches!( + name.as_str(), + "get_or_init" | "get_or_try_init" | "lock" | "read" + ) + || (name == "get" && m.args.is_empty()); + if pass { + return recv; + } + let recv = recv?; + if let Some(t) = self.builder_step(&recv, &name) { + return Some(t); + } + self.sh + .index + .methods + .get(&(recv, name))? + .iter() + .find_map(|f| self.sh.index.return_type(self.sh.ctx, f)) + } + + /// 클라이언트 빌더 체인의 다음 타입. + fn builder_step(&self, recv: &str, method: &str) -> Option { + if let Some(lib) = &self.libs.reqwest { + for (builder, client) in [ + ("ClientBuilder", "Client"), + ("blocking::ClientBuilder", "blocking::Client"), + ] { + if recv == format!("{lib}::{builder}") { + return Some(if method == "build" { + format!("{lib}::{client}") + } else { + recv.to_string() + }); + } + } + } + if let Some((lib, _)) = &self.libs.ureq { + if recv == format!("{lib}::AgentBuilder") { + return Some(if method == "build" { + format!("{lib}::Agent") + } else { + recv.to_string() + }); + } + if recv == format!("{lib}::ConfigBuilder") { + return Some(if method == "build" { + format!("{lib}::Config") + } else { + recv.to_string() + }); + } + if recv == format!("{lib}::Config") && method == "new_agent" { + return Some(format!("{lib}::Agent")); + } + } + None + } + + /// 타입이 요청 클라이언트인가. + fn client_of(&self, ty: Option<&str>) -> Option { + let ty = ty?; + if let Some(lib) = &self.libs.reqwest { + if ty == format!("{lib}::Client") || ty == format!("{lib}::blocking::Client") { + return Some(Client::Reqwest); + } + } + if let Some((lib, _)) = &self.libs.ureq { + if ty == format!("{lib}::Agent") { + return Some(Client::Ureq); + } + } + None + } + + // ── 값 ─────────────────────────────────────────────── + + /// 식의 문자열·URL 값. + fn eval(&self, e: &syn::Expr) -> Val { + self.eval_depth(e, 0) + } + + fn eval_depth(&self, e: &syn::Expr, depth: usize) -> Val { + if depth > 32 { + return Val::unknown(); + } + let d = depth + 1; + match e { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) => Val::Str(vec![Piece::Lit(s.value())], None), + syn::Expr::Path(p) => self.eval_path(&crate::harvest::path_segments(&p.path), d), + syn::Expr::Field(f) => self.eval_field(f), + syn::Expr::Macro(m) => self.eval_macro(&m.mac, d), + syn::Expr::Binary(b) if matches!(b.op, syn::BinOp::Add(_)) => { + concat(self.eval_depth(&b.left, d), self.eval_depth(&b.right, d)) + } + syn::Expr::Reference(r) => self.eval_depth(&r.expr, d), + syn::Expr::Paren(p) => self.eval_depth(&p.expr, d), + syn::Expr::Group(g) => self.eval_depth(&g.expr, d), + syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Deref(_)) => { + self.eval_depth(&u.expr, d) + } + syn::Expr::Try(t) => self.eval_depth(&t.expr, d), + syn::Expr::MethodCall(m) => self.eval_method(m, d), + syn::Expr::Call(c) => self.eval_call(c, d), + _ => Val::unknown(), + } + } + + /// 경로 값 — 지역 변수, 문자열 상수·static. `depth`는 상수가 상수를 가리키는 사슬에도 + /// 이어져 깊은 사슬이 스택을 넘기지 않게 한다. + fn eval_path(&self, segs: &[String], depth: usize) -> Val { + if let [one] = segs { + if let Some(b) = self.env.get(one) { + return b.val.clone(); + } + } + match self.resolve_path(segs) { + Some(id) if self.sh.index.consts.contains_key(&id) => self.eval_const(&id, depth), + _ => Val::unknown(), + } + } + + /// 상수·static의 값(순환 방지 캐시). 깊이 상한에 닿은 사슬은 모르는 값(`baseRef`)이다. + fn eval_const(&self, id: &str, depth: usize) -> Val { + if let Some(cached) = self.sh.consts.borrow().get(id) { + return cached + .clone() + .unwrap_or_else(|| Val::Other(Origin::Base(id.to_string()))); + } + self.sh.consts.borrow_mut().insert(id.to_string(), None); + let Some(c) = self.sh.index.consts.get(id) else { + return Val::unknown(); + }; + let scanner = Scanner { + sh: self.sh, + mode: self.mode, + module: c.module.clone(), + file: self.file.clone(), + imports: self.sh.index.imports(self.sh.ctx, &c.module), + libs: self.libs, + self_ty: c.self_ty.clone(), + env: Env::default(), + mutated: BTreeSet::new(), + in_wrapper: false, + }; + let v = scanner.eval_depth(c.expr, depth); + let known = match v { + Val::Str(..) | Val::Url(..) => Some(v.with_ref(Some(id.to_string()))), + Val::Other(_) => None, + }; + self.sh + .consts + .borrow_mut() + .insert(id.to_string(), known.clone()); + known.unwrap_or_else(|| Val::Other(Origin::Base(id.to_string()))) + } + + /// 필드 값 — 생성자들이 모두 같은 리터럴·상수로 채운 필드만 값이 있다. + fn eval_field(&self, f: &syn::ExprField) -> Val { + let syn::Member::Named(name) = &f.member else { + return Val::unknown(); + }; + let Some(owner) = self.ty(&f.base) else { + return Val::unknown(); + }; + if !self.sh.index.structs.contains_key(&owner) { + return Val::unknown(); + } + let name = name.to_string(); + let id = format!("{owner}::{name}"); + match self.sh.resolved.borrow().get(&(owner, name)) { + Some(v) => v.clone().with_ref(Some(id)), + None => Val::Other(Origin::Base(id)), + } + } + + /// 메서드 호출 값 — 문자열 변환, `Url::join`. + fn eval_method(&self, m: &syn::ExprMethodCall, d: usize) -> Val { + let name = m.method.to_string(); + if PASS_THROUGH.contains(&name.as_str()) { + return self.eval_depth(&m.receiver, d); + } + if name == "join" && m.args.len() == 1 { + if let Val::Url(u, r) = self.eval_depth(&m.receiver, d) { + let arg = self.eval_depth(&m.args[0], d); + return Val::Url(u.join(&arg.pieces()), r); + } + } + Val::unknown() + } + + /// 호출 값 — `Url::parse`, `String::from`, `String::new`. + fn eval_call(&self, c: &syn::ExprCall, d: usize) -> Val { + let syn::Expr::Path(p) = &*c.func else { + return Val::unknown(); + }; + let segs = crate::harvest::path_segments(&p.path); + if self.is_workspace(&segs) { + return Val::unknown(); + } + let full = self.expand(&segs); + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + let url_lib = |l: &str| l == "url" || self.libs.reqwest.as_deref() == Some(l); + match parts.as_slice() { + [lib, "Url", "parse"] if url_lib(lib) && c.args.len() == 1 => { + let arg = self.eval_depth(&c.args[0], d); + let r = arg.base_ref(); + Val::Url(UrlVal::parse(&arg.pieces(), true), r) + } + ["String", "from"] | ["std", "string", "String", "from"] if c.args.len() == 1 => { + self.eval_depth(&c.args[0], d) + } + ["String", "new"] | ["String", "default"] => Val::Str(Vec::new(), None), + _ => Val::unknown(), + } + } + + /// 매크로 값 — `format!` 계열과 `concat!`. + fn eval_macro(&self, mac: &syn::Macro, d: usize) -> Val { + let name = mac + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(); + let args = mac.parse_body_with( + syn::punctuated::Punctuated::::parse_terminated, + ); + let Ok(args) = args else { + return Val::unknown(); + }; + let args: Vec = args.into_iter().collect(); + match name.as_str() { + "format" | "format_args" => self.eval_format(&args, d), + "concat" => { + let mut pieces = Vec::new(); + for a in &args { + match a { + syn::Expr::Lit(l) => match &l.lit { + syn::Lit::Str(s) => pieces.push(Piece::Lit(s.value())), + syn::Lit::Int(i) => pieces.push(Piece::Lit(i.base10_digits().into())), + syn::Lit::Char(c) => pieces.push(Piece::Lit(c.value().to_string())), + syn::Lit::Bool(b) => pieces.push(Piece::Lit(b.value.to_string())), + _ => pieces.push(Piece::Value(Origin::Unknown)), + }, + _ => pieces.push(Piece::Value(Origin::Unknown)), + } + } + Val::Str(pieces, None) + } + _ => Val::unknown(), + } + } + + /// `format!("..", args)` — Display 자리표시자만 값을 잇고, 서식 지정이 있으면 모르는 + /// 값이다(`{:?}`는 따옴표를 붙인다). + fn eval_format(&self, args: &[syn::Expr], d: usize) -> Val { + let Some((fmt, rest)) = args.split_first() else { + return Val::unknown(); + }; + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(fmt), + .. + }) = fmt + else { + return Val::unknown(); + }; + let mut positional: Vec<&syn::Expr> = Vec::new(); + let mut named: BTreeMap = BTreeMap::new(); + for a in rest { + match a { + syn::Expr::Assign(asg) => { + if let syn::Expr::Path(p) = &*asg.left { + if let Some(id) = p.path.get_ident() { + named.insert(id.to_string(), &asg.right); + continue; + } + } + return Val::unknown(); + } + other => positional.push(other), + } + } + let Some(segments) = parse_format(&fmt.value()) else { + return Val::unknown(); + }; + let mut pieces = Vec::new(); + let mut next = 0usize; + let mut lead_ref = None; + for (i, seg) in segments.into_iter().enumerate() { + match seg { + FmtSeg::Lit(t) => pieces.push(Piece::Lit(t)), + FmtSeg::Arg { key, spec } => { + let v = match key { + FmtKey::Next => { + next += 1; + positional.get(next - 1).map(|e| self.eval_depth(e, d)) + } + FmtKey::Index(n) => positional.get(n).map(|e| self.eval_depth(e, d)), + FmtKey::Name(n) => match named.get(&n) { + Some(e) => Some(self.eval_depth(e, d)), + None => Some(self.eval_path(&[n], d)), + }, + }; + let Some(v) = v else { + return Val::unknown(); + }; + if i == 0 { + lead_ref = v.base_ref(); + } + if spec.is_empty() { + pieces.extend(v.pieces()); + } else { + let origin = match v { + Val::Other(o) => o, + _ => Origin::Unknown, + }; + pieces.push(Piece::Value(origin)); + } + } + } + } + Val::Str(pieces, lead_ref) + } + + /// 지역 변수 초기식 — 모든 가지가 `?`로 시작하거나 빈 문자열이면 query 꼬리다. + fn eval_local(&self, e: &syn::Expr) -> Val { + if matches!(e, syn::Expr::If(_) | syn::Expr::Match(_)) { + let mut branches = Vec::new(); + if branch_tails(e, &mut branches) + && !branches.is_empty() + && branches.iter().all(|b| self.is_query_like(b)) + { + return Val::Str(vec![Piece::QueryTail], None); + } + return Val::unknown(); + } + self.eval(e) + } + + /// 값이 빈 문자열이거나 `?` 리터럴로 시작하는가. + fn is_query_like(&self, e: &syn::Expr) -> bool { + match self.eval(e) { + Val::Str(p, _) => match p.first() { + None => true, + Some(Piece::Lit(l)) => l.is_empty() || l.starts_with('?'), + Some(_) => false, + }, + _ => false, + } + } + + // ── 호출 사실 ───────────────────────────────────────── + + /// 호출 위치의 CallSite를 기록한다(`Emit` 단계만). + /// + /// `method_param`은 동사 식이 매개변수인지다 — 동사를 흘려보내는 함수도 선언되지 + /// 않은 래퍼 싱크다. + fn record( + &self, + span: proc_macro2::Span, + method: Option, + v: &Val, + lib: Client, + method_param: bool, + ) { + let dots = !(lib == Client::Ureq && self.libs.ureq.as_ref().is_some_and(|(_, m)| *m >= 3)); + let outcome = match v { + Val::Str(p, _) => UrlVal::parse(p, dots).outcome(), + Val::Url(u, _) => u.outcome(), + Val::Other(_) => dynamic(), + }; + let dynamic_path = matches!(outcome, Outcome::Dynamic { .. }); + let param_driven = (dynamic_path && passes_param(&v.pieces())) || method_param; + self.push_call( + span, + method, + outcome, + v.base_ref(), + None, + None, + param_driven, + ); + } + + /// 식이 감싸는 함수의 매개변수 그대로인가. + fn is_param(&self, e: &syn::Expr) -> bool { + match strip(e) { + syn::Expr::Path(p) => p.path.get_ident().is_some_and(|id| { + matches!( + self.env.get(&id.to_string()), + Some(Binding { + val: Val::Other(Origin::Param(_)), + .. + }) + ) + }), + _ => false, + } + } + + /// 사실 하나를 결과에 넣는다. 선언된 래퍼 본문의 dynamic 호출은 싣지 않는다. + #[allow(clippy::too_many_arguments)] + fn push_call( + &self, + span: proc_macro2::Span, + method: Option, + outcome: Outcome, + base_ref: Option, + service: Option, + wrapper: Option, + param_driven: bool, + ) { + if self.mode != Mode::Emit { + return; + } + let mut out = self.sh.out.borrow_mut(); + if let Some(i) = wrapper { + *out.wrapper_calls.entry(i).or_default() += 1; + } + if outcome == Outcome::Unrequestable { + out.unrequestable += 1; + return; + } + let unproven = matches!(outcome, Outcome::Dynamic { .. }) || method.is_none(); + if unproven && self.in_wrapper && wrapper.is_none() { + return; + } + if unproven && param_driven && wrapper.is_none() { + out.undeclared += 1; + } + out.calls.push(CallSite { + loc: Loc { + file: self.file.clone(), + span, + }, + method, + outcome, + base_ref, + service, + }); + } + + /// `reqwest::Method::GET`·`http::Method::GET` 경로의 동사 — 라이브러리 상수만 푼다. + fn library_method(&self, e: &syn::Expr) -> Option { + let syn::Expr::Path(p) = strip(e) else { + return None; + }; + let full = self.expand(&crate::harvest::path_segments(&p.path)); + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + let lib_ok = |l: &str| l == "http" || self.libs.reqwest.as_deref() == Some(l); + match parts.as_slice() { + [lib, "Method", v] if lib_ok(lib) && VERBS.contains(v) => Some(v.to_string()), + _ => None, + } + } + + /// 문자열 리터럴(상수 포함) 동사 — ureq `request("GET", url)`. + fn literal_method(&self, e: &syn::Expr) -> Option { + match self.eval(e) { + Val::Str(p, _) => match p.as_slice() { + [Piece::Lit(s)] if VERBS.contains(&s.as_str()) => Some(s.clone()), + _ => None, + }, + _ => None, + } + } + + /// 자유 함수·연관 함수 호출이 요청·래퍼 호출인가. + fn check_call(&mut self, c: &syn::ExprCall) { + let syn::Expr::Path(p) = &*c.func else { + return; + }; + let segs = crate::harvest::path_segments(&p.path); + if self.is_workspace(&segs) { + self.check_wrapper_call(c, &segs); + return; + } + let full = self.expand(&segs); + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + let args: Vec<&syn::Expr> = c.args.iter().collect(); + let span = c.span(); + if let Some(lib) = self.libs.reqwest.clone() { + match (parts.as_slice(), args.as_slice()) { + ([l, "get"] | [l, "blocking", "get"], [url]) if *l == lib => { + let v = self.eval(url); + self.record(span, Some("GET".into()), &v, Client::Reqwest, false); + } + ([l, "Request", "new"] | [l, "blocking", "Request", "new"], [m, url]) + if *l == lib => + { + let method = self.library_method(m); + let v = self.eval(url); + self.record(span, method, &v, Client::Reqwest, self.is_param(m)); + } + _ => {} + } + } + if let Some((lib, _)) = self.libs.ureq.clone() { + match (parts.as_slice(), args.as_slice()) { + ([l, verb], [url]) if *l == lib && is_verb_method(Client::Ureq, verb) => { + let v = self.eval(url); + self.record( + span, + Some(verb.to_ascii_uppercase()), + &v, + Client::Ureq, + false, + ); + } + ([l, "request" | "request_url"], [m, url]) if *l == lib => { + let method = self.literal_method(m); + let v = self.eval(url); + self.record(span, method, &v, Client::Ureq, self.is_param(m)); + } + _ => {} + } + } + } + + /// 메서드 호출이 클라이언트 요청·래퍼 호출인가. 보내기 메서드면 수신자를 + /// 증명하지 못한 요청 모양 호출을 센다. + fn check_method(&mut self, m: &syn::ExprMethodCall) { + let name = m.method.to_string(); + let recv_ty = self.ty(&m.receiver); + let client = self.client_of(recv_ty.as_deref()); + let args: Vec<&syn::Expr> = m.args.iter().collect(); + let span = m.span(); + match (client, name.as_str(), args.as_slice()) { + (Some(k), verb, [url]) if is_verb_method(k, verb) => { + let v = self.eval(url); + self.record(span, Some(verb.to_ascii_uppercase()), &v, k, false); + return; + } + (Some(k), "request", [method_expr, url]) => { + let method = match k { + Client::Reqwest => self.library_method(method_expr), + Client::Ureq => self.literal_method(method_expr), + }; + let v = self.eval(url); + self.record(span, method, &v, k, self.is_param(method_expr)); + return; + } + _ => {} + } + if SEND_METHODS.contains(&name.as_str()) && self.mode == Mode::Emit { + if let Some(verb_call) = request_call_in_chain(&m.receiver) { + if self + .client_of(self.ty(&verb_call.receiver).as_deref()) + .is_none() + && (self.libs.reqwest.is_some() || self.libs.ureq.is_some()) + { + self.sh.out.borrow_mut().unproven += 1; + } + } + } + if let Some(owner) = recv_ty { + self.check_wrapper_method(m, &owner, &name); + } + } + + /// 워크스페이스 함수·연관 함수·튜플 구조체 생성이 선언된 래퍼인가. + fn check_wrapper_call(&mut self, c: &syn::ExprCall, segs: &[String]) { + let Some(id) = self.resolve_path(segs) else { + return; + }; + let args: Vec = c.args.iter().map(|a| self.call_arg(None, a)).collect(); + let exprs: Vec<&syn::Expr> = c.args.iter().collect(); + if let Some(&i) = self.sh.ctor_wrappers.get(&id) { + self.emit_wrapper(i, c.span(), &args, &exprs); + return; + } + let vertex = self.sh.ctx.resolve_vertex(&self.module, segs).unwrap_or(id); + let Some(&i) = self.sh.fn_wrappers.get(&vertex) else { + return; + }; + // 메서드를 경로로 부르면(UFCS) 첫 인자가 수신자다 — 선언 index는 수신자를 뺀다. + let skip = usize::from(self.has_receiver(&vertex)); + self.emit_wrapper( + i, + c.span(), + &args[skip.min(args.len())..], + &exprs[skip.min(exprs.len())..], + ); + } + + /// 정점 ID가 self 수신자를 받는 메서드인가. + fn has_receiver(&self, vertex: &str) -> bool { + let Some((owner, name)) = vertex.rsplit_once("::") else { + return false; + }; + let owner = owner.split("::<").next().unwrap_or(owner); + self.sh + .index + .methods + .get(&(owner.to_string(), name.to_string())) + .is_some_and(|fs| fs.iter().any(|f| f.sig.receiver().is_some())) + } + + /// 수신자 타입이 래퍼 소유 타입인 메서드 호출. + fn check_wrapper_method(&mut self, m: &syn::ExprMethodCall, owner: &str, name: &str) { + let inherent = format!("{owner}::{name}"); + let trait_prefix = format!("{owner}::<"); + let suffix = format!(">::{name}"); + let hit = self.sh.fn_wrappers.iter().find(|(id, _)| { + **id == inherent || (id.starts_with(&trait_prefix) && id.ends_with(&suffix)) + }); + let Some((_, &i)) = hit else { + return; + }; + let args: Vec = m.args.iter().map(|a| self.call_arg(None, a)).collect(); + let exprs: Vec<&syn::Expr> = m.args.iter().collect(); + self.emit_wrapper(i, m.span(), &args, &exprs); + } + + /// 구조체 리터럴 생성 래퍼(`Endpoint { method, path }`). + fn check_wrapper_struct(&mut self, s: &syn::ExprStruct) { + let Some(id) = self.resolve_path(&crate::harvest::path_segments(&s.path)) else { + return; + }; + let Some(&i) = self.sh.ctor_wrappers.get(&id) else { + return; + }; + let mut args = Vec::new(); + let mut exprs = Vec::new(); + for fv in &s.fields { + let label = match &fv.member { + syn::Member::Named(n) => Some(n.to_string()), + syn::Member::Unnamed(_) => None, + }; + args.push(self.call_arg(label, &fv.expr)); + exprs.push(&fv.expr); + } + self.emit_wrapper(i, s.span(), &args, &exprs); + } + + /// 호출 인자 하나를 바인딩 입력으로 바꾼다. + fn call_arg(&self, label: Option, e: &syn::Expr) -> CallArg { + let value = match strip(e) { + syn::Expr::Path(p) => { + let segs = crate::harvest::path_segments(&p.path); + let local = matches!(segs.as_slice(), [one] if self.env.get(one).is_some()); + match self.eval_path(&segs, 0) { + Val::Str(pieces, _) if !local => match pieces.as_slice() { + [Piece::Lit(s)] => ArgValue::Literal(s.clone()), + _ => ArgValue::Other, + }, + _ if local => ArgValue::Other, + _ => segs + .last() + .map_or(ArgValue::Other, |s| ArgValue::EnumCase(s.clone())), + } + } + other => match self.eval(other) { + Val::Str(pieces, _) => match pieces.as_slice() { + [Piece::Lit(s)] => ArgValue::Literal(s.clone()), + _ => ArgValue::Other, + }, + _ => ArgValue::Other, + }, + }; + CallArg { label, value } + } + + /// 선언된 래퍼 호출을 사실로 낸다. + fn emit_wrapper( + &mut self, + i: usize, + span: proc_macro2::Span, + args: &[CallArg], + exprs: &[&syn::Expr], + ) { + let w = &self.sh.wrappers[i]; + let method = wrappers::bind_method(&w.method_spec(), args); + let path_pos = wrappers::find_arg(&w.path_arg, args) + .and_then(|a| args.iter().position(|x| std::ptr::eq(x, a))); + let anchor = if w.path_anchor == "root" { + PathAnchor::Root + } else { + PathAnchor::Base + }; + // 래퍼 경로 인자는 base 뒤의 경로라 baseRef를 싣지 않는다(base는 래퍼 안에 있다). + let outcome = match path_pos.and_then(|p| exprs.get(p)) { + Some(e) => wrapper_outcome(&self.eval(e).pieces(), anchor), + None => dynamic(), + }; + let service = w.service.clone(); + self.push_call(span, method, outcome, None, service, Some(i), false); + } + + /// 모델링하지 않는 클라이언트 크레이트 경로의 사용을 센다. + fn check_unmodelled(&mut self, segs: &[String]) { + if self.mode != Mode::Emit || self.libs.unmodelled.is_empty() || self.is_workspace(segs) { + return; + } + let full = self.expand(segs); + let Some(first) = full.first() else { return }; + if !self.libs.unmodelled.contains(first) { + return; + } + // hyper·hyper-util은 서버에도 쓰인다 — client 경로만 센다. + let server_capable = first == "hyper" || first == "hyper_util"; + if server_capable && !full.iter().any(|s| s == "client" || s == "Client") { + return; + } + *self + .sh + .out + .borrow_mut() + .unmodelled + .entry(first.clone()) + .or_default() += 1; + } + + /// 패턴의 이름들을 바인딩한다. 단순 이름이면 값·타입을 싣고, 분해 패턴은 모르는 값이다. + fn bind_pat(&mut self, pat: &syn::Pat, val: Val, ty: Option) { + match pat { + syn::Pat::Ident(i) if i.subpat.is_none() => { + let name = i.ident.to_string(); + let val = if self.mutated.contains(&name) { + Val::unknown() + } else { + val + }; + self.env.bind(name, Binding { val, ty }); + } + syn::Pat::Type(t) => { + let ty = self.resolve_type(&t.ty).or(ty); + self.bind_pat(&t.pat, val, ty); + } + other => { + for name in pat_names(other) { + self.env.bind( + name, + Binding { + val: Val::unknown(), + ty: None, + }, + ); + } + } + } + } + + /// 조건식 안의 `let` 패턴 이름을 바인딩한다(`if let`·`while let`·let 체인). + fn bind_lets(&mut self, cond: &syn::Expr) { + match cond { + syn::Expr::Let(l) => { + let v = Val::unknown(); + self.bind_pat(&l.pat, v, None); + } + syn::Expr::Binary(b) if matches!(b.op, syn::BinOp::And(_)) => { + self.bind_lets(&b.left); + self.bind_lets(&b.right); + } + syn::Expr::Paren(p) => self.bind_lets(&p.expr), + _ => {} + } + } +} + +impl<'ast> Visit<'ast> for Scanner<'_, '_> { + fn visit_block(&mut self, b: &'ast syn::Block) { + self.env.push(); + syn::visit::visit_block(self, b); + self.env.pop(); + } + + fn visit_local(&mut self, l: &'ast syn::Local) { + if let Some(init) = &l.init { + self.visit_expr(&init.expr); + if let Some((_, div)) = &init.diverge { + self.visit_expr(div); + } + } + let (val, ty) = match &l.init { + Some(init) if init.diverge.is_none() => { + (self.eval_local(&init.expr), self.ty(&init.expr)) + } + _ => (Val::unknown(), None), + }; + self.bind_pat(&l.pat, val, ty); + } + + fn visit_expr_closure(&mut self, c: &'ast syn::ExprClosure) { + self.env.push(); + for input in &c.inputs { + self.bind_pat(input, Val::unknown(), None); + } + self.visit_expr(&c.body); + self.env.pop(); + } + + fn visit_expr_if(&mut self, e: &'ast syn::ExprIf) { + self.visit_expr(&e.cond); + self.env.push(); + self.bind_lets(&e.cond); + self.visit_block(&e.then_branch); + self.env.pop(); + if let Some((_, other)) = &e.else_branch { + self.visit_expr(other); + } + } + + fn visit_expr_while(&mut self, e: &'ast syn::ExprWhile) { + self.visit_expr(&e.cond); + self.env.push(); + self.bind_lets(&e.cond); + self.visit_block(&e.body); + self.env.pop(); + } + + fn visit_expr_for_loop(&mut self, e: &'ast syn::ExprForLoop) { + self.visit_expr(&e.expr); + self.env.push(); + self.bind_pat(&e.pat, Val::unknown(), None); + self.visit_block(&e.body); + self.env.pop(); + } + + fn visit_arm(&mut self, a: &'ast syn::Arm) { + self.env.push(); + self.bind_pat(&a.pat, Val::unknown(), None); + if let Some((_, g)) = &a.guard { + self.visit_expr(g); + } + self.visit_expr(&a.body); + self.env.pop(); + } + + fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) { + if !is_test_item(&f.attrs) { + self.scan_fn(None, &f.sig, &f.block); + } + } + + fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) { + if !is_test_item(&i.attrs) { + self.scan_impl(i); + } + } + + fn visit_item_mod(&mut self, _: &'ast syn::ItemMod) { + // 모듈은 모듈 트리가 따로 훑는다(테스트 모듈 제외 규칙 포함). + } + + fn visit_macro(&mut self, mac: &'ast syn::Macro) { + // 매크로 인자가 쉼표로 나뉜 식이면 그 안의 요청도 본다(`tokio::join!` 등). + let parsed = mac.parse_body_with( + syn::punctuated::Punctuated::::parse_terminated, + ); + if let Ok(exprs) = parsed { + for e in &exprs { + self.visit_expr(e); + } + } + } + + fn visit_expr_call(&mut self, c: &'ast syn::ExprCall) { + self.check_call(c); + syn::visit::visit_expr_call(self, c); + } + + fn visit_expr_method_call(&mut self, m: &'ast syn::ExprMethodCall) { + if MUTATORS.contains(&m.method.to_string().as_str()) { + self.poison_field(&m.receiver); + } + self.check_method(m); + syn::visit::visit_expr_method_call(self, m); + } + + fn visit_expr_struct(&mut self, s: &'ast syn::ExprStruct) { + match self.mode { + Mode::Collect => self.collect_struct(s), + Mode::Emit => self.check_wrapper_struct(s), + } + syn::visit::visit_expr_struct(self, s); + } + + fn visit_expr_path(&mut self, p: &'ast syn::ExprPath) { + self.check_unmodelled(&crate::harvest::path_segments(&p.path)); + syn::visit::visit_expr_path(self, p); + } + + fn visit_expr_assign(&mut self, a: &'ast syn::ExprAssign) { + self.poison_field(&a.left); + syn::visit::visit_expr_assign(self, a); + } + + fn visit_expr_binary(&mut self, b: &'ast syn::ExprBinary) { + if is_compound_assign(&b.op) { + self.poison_field(&b.left); + } + syn::visit::visit_expr_binary(self, b); + } + + fn visit_expr_reference(&mut self, r: &'ast syn::ExprReference) { + if r.mutability.is_some() { + self.poison_field(&r.expr); + } + syn::visit::visit_expr_reference(self, r); + } +} + +impl Scanner<'_, '_> { + /// 구조체 리터럴의 필드 값을 누적한다(`Collect`). + fn collect_struct(&self, s: &syn::ExprStruct) { + let Some(id) = self.resolve_path(&crate::harvest::path_segments(&s.path)) else { + return; + }; + let Some(info) = self.sh.index.structs.get(&id) else { + return; + }; + let mut listed = BTreeSet::new(); + for fv in &s.fields { + if let syn::Member::Named(n) = &fv.member { + let name = n.to_string(); + self.sh.add_field(&id, &name, self.eval(&fv.expr)); + listed.insert(name); + } + } + if s.rest.is_some() { + for f in info.fields.keys().filter(|f| !listed.contains(*f)) { + self.sh.add_field(&id, f, Val::unknown()); + } + } + } + + /// 필드를 제자리 수정하는 식이면 그 필드 이름을 믿지 않는다(`Collect`). + fn poison_field(&self, target: &syn::Expr) { + if self.mode != Mode::Collect { + return; + } + if let syn::Expr::Field(f) = strip(target) { + if let syn::Member::Named(n) = &f.member { + let owner = self.ty(&f.base); + self.sh.poisoned.borrow_mut().insert((owner, n.to_string())); + } + } + } +} + +/// 리터럴·상수만으로 된 값인가 — 생성 위치의 매개변수·지역 값에서 온 필드는 +/// 인스턴스마다 다르므로 필드 값으로 확정하지 않는다. +fn is_constant(v: &Val) -> bool { + let all_lit = |p: &[Piece]| p.iter().all(|x| matches!(x, Piece::Lit(_))); + match v { + Val::Str(p, _) => all_lit(p), + Val::Url(u, _) => { + u.authority.is_some() + && matches!(&u.path, super::super::compose::UrlPath::Known { anchor: PathAnchor::Root, pieces } if all_lit(pieces)) + } + Val::Other(_) => false, + } +} + +/// 매개변수를 URL로 그대로 흘려보내는 모양인가 — URL 전체가 매개변수이거나, 끝의 +/// 매개변수가 `/` 없이 앞 리터럴(보통 base)에 붙는다. 세그먼트 일부를 채우는 +/// 매개변수(`/files/{name}.json`)는 래퍼 선언으로 풀리지 않으므로 세지 않는다. +fn passes_param(pieces: &[Piece]) -> bool { + match pieces { + [Piece::Value(Origin::Param(_))] => true, + [.., Piece::Lit(prev), Piece::Value(Origin::Param(_))] => !prev.ends_with('/'), + [.., Piece::Value(_), Piece::Value(Origin::Param(_))] => true, + _ => false, + } +} + +/// 요청 라이브러리가 그 동사 메서드를 갖는가. +fn is_verb_method(k: Client, name: &str) -> bool { + let reqwest = ["get", "post", "put", "patch", "delete", "head"]; + let ureq = [ + "get", "post", "put", "patch", "delete", "head", "options", "trace", + ]; + match k { + Client::Reqwest => reqwest.contains(&name), + Client::Ureq => ureq.contains(&name), + } +} + +/// 보내기 호출의 수신자 체인에서 빌더 메서드를 건너 동사·`request` 호출을 찾는다. +fn request_call_in_chain(e: &syn::Expr) -> Option<&syn::ExprMethodCall> { + let syn::Expr::MethodCall(m) = strip(e) else { + return None; + }; + let name = m.method.to_string(); + let verb = ["get", "post", "put", "patch", "delete", "head"].contains(&name.as_str()) + && m.args.len() == 1; + if verb || (name == "request" && m.args.len() == 2) { + return Some(m); + } + if BUILDER_METHODS.contains(&name.as_str()) { + return request_call_in_chain(&m.receiver); + } + None +} + +/// 래퍼 경로 인자의 조립 결과 — 전체 URL이면 절대 해석, `/`로 시작하면 선언 +/// 앵커, 상대 경로는 결합 방식을 모르므로 dynamic(base 앵커면 `ambiguous-base-join:`). +fn wrapper_outcome(pieces: &[Piece], anchor: PathAnchor) -> Outcome { + match pieces.first() { + Some(Piece::Lit(l)) if l.contains("://") => UrlVal::parse(pieces, true).outcome(), + Some(Piece::Lit(l)) if l.starts_with('/') => { + super::super::compose::compose_path(anchor, pieces, None, false) + } + Some(Piece::Lit(_)) => Outcome::Dynamic { + prefix: None, + anchor, + ambiguous: anchor == PathAnchor::Base, + masked_segments: 0, + }, + _ => Outcome::Dynamic { + prefix: None, + anchor, + ambiguous: false, + masked_segments: 0, + }, + } +} + +/// 접두사 없는 dynamic. +fn dynamic() -> Outcome { + Outcome::Dynamic { + prefix: None, + anchor: PathAnchor::Base, + ambiguous: false, + masked_segments: 0, + } +} + +/// 두 값을 문자열로 잇는다(`a + b`). +fn concat(a: Val, b: Val) -> Val { + let r = match &a { + Val::Str(p, _) if p.is_empty() => b.base_ref(), + _ => a.base_ref(), + }; + let mut pieces = a.pieces(); + pieces.extend(b.pieces()); + Val::Str(pieces, r) +} + +/// 괄호·참조·그룹을 벗긴다. +fn strip(e: &syn::Expr) -> &syn::Expr { + match e { + syn::Expr::Paren(p) => strip(&p.expr), + syn::Expr::Reference(r) => strip(&r.expr), + syn::Expr::Group(g) => strip(&g.expr), + other => other, + } +} + +/// 복합 대입 연산자인가(`+=` 등). +fn is_compound_assign(op: &syn::BinOp) -> bool { + use syn::BinOp::*; + matches!( + op, + AddAssign(_) + | SubAssign(_) + | MulAssign(_) + | DivAssign(_) + | RemAssign(_) + | BitXorAssign(_) + | BitAndAssign(_) + | BitOrAssign(_) + | ShlAssign(_) + | ShrAssign(_) + ) +} + +/// 패턴이 묶는 모든 이름. +fn pat_names(p: &syn::Pat) -> Vec { + struct Names(Vec); + impl<'ast> Visit<'ast> for Names { + fn visit_pat_ident(&mut self, i: &'ast syn::PatIdent) { + self.0.push(i.ident.to_string()); + syn::visit::visit_pat_ident(self, i); + } + } + let mut n = Names(Vec::new()); + n.visit_pat(p); + n.0 +} + +/// 함수 본문에서 제자리 수정되는 지역 변수 이름(대입·복합 대입·`&mut`·수정 메서드). +fn mutations(block: &syn::Block) -> BTreeSet { + struct Finder(BTreeSet); + impl Finder { + fn target(&mut self, e: &syn::Expr) { + if let syn::Expr::Path(p) = strip(e) { + if let Some(id) = p.path.get_ident() { + self.0.insert(id.to_string()); + } + } + } + } + impl<'ast> Visit<'ast> for Finder { + fn visit_expr_assign(&mut self, a: &'ast syn::ExprAssign) { + self.target(&a.left); + syn::visit::visit_expr_assign(self, a); + } + fn visit_expr_binary(&mut self, b: &'ast syn::ExprBinary) { + if is_compound_assign(&b.op) { + self.target(&b.left); + } + syn::visit::visit_expr_binary(self, b); + } + fn visit_expr_reference(&mut self, r: &'ast syn::ExprReference) { + if r.mutability.is_some() { + self.target(&r.expr); + } + syn::visit::visit_expr_reference(self, r); + } + fn visit_expr_method_call(&mut self, m: &'ast syn::ExprMethodCall) { + if MUTATORS.contains(&m.method.to_string().as_str()) { + self.target(&m.receiver); + } + syn::visit::visit_expr_method_call(self, m); + } + } + let mut f = Finder(BTreeSet::new()); + f.visit_block(block); + f.0 +} + +/// `if`/`match` 식의 가지 끝 식들. 끝 식이 없는 가지가 있으면 false. +fn branch_tails<'e>(e: &'e syn::Expr, out: &mut Vec<&'e syn::Expr>) -> bool { + match e { + syn::Expr::If(i) => { + let Some((_, other)) = &i.else_branch else { + return false; + }; + block_tail(&i.then_branch).is_some_and(|t| branch_tails(t, out)) + && branch_tails(other, out) + } + syn::Expr::Match(m) => m.arms.iter().all(|a| branch_tails(&a.body, out)), + syn::Expr::Block(b) => block_tail(&b.block).is_some_and(|t| branch_tails(t, out)), + syn::Expr::Paren(p) => branch_tails(&p.expr, out), + other => { + out.push(other); + true + } + } +} + +/// 블록의 끝 식(세미콜론 없는 마지막 식). +fn block_tail(b: &syn::Block) -> Option<&syn::Expr> { + match b.stmts.last() { + Some(syn::Stmt::Expr(e, None)) => Some(e), + _ => None, + } +} + +/// `format!` 서식 문자열 조각. +enum FmtSeg { + Lit(String), + Arg { key: FmtKey, spec: String }, +} + +/// 자리표시자가 가리키는 인자. +enum FmtKey { + Next, + Index(usize), + Name(String), +} + +/// 서식 문자열을 읽는다. 너비·정밀도를 인자로 받는 `$`·`*` 지정처럼 인자 순서를 +/// 바꾸는 서식은 None(값 전체를 모른다). +fn parse_format(fmt: &str) -> Option> { + let mut out = Vec::new(); + let mut lit = String::new(); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '{' if chars.peek() == Some(&'{') => { + chars.next(); + lit.push('{'); + } + '}' if chars.peek() == Some(&'}') => { + chars.next(); + lit.push('}'); + } + '{' => { + let mut inner = String::new(); + for c in chars.by_ref() { + if c == '}' { + break; + } + inner.push(c); + } + if !lit.is_empty() { + out.push(FmtSeg::Lit(std::mem::take(&mut lit))); + } + let (name, spec) = inner.split_once(':').unwrap_or((&inner, "")); + if spec.contains(['$', '*']) { + return None; + } + let name = name.trim(); + let key = if name.is_empty() { + FmtKey::Next + } else if let Ok(n) = name.parse::() { + FmtKey::Index(n) + } else { + FmtKey::Name(name.to_string()) + }; + out.push(FmtSeg::Arg { + key, + spec: spec.to_string(), + }); + } + other => lit.push(other), + } + } + if !lit.is_empty() { + out.push(FmtSeg::Lit(lit)); + } + Some(out) +} diff --git a/src/source/routes/common.rs b/src/source/routes/common.rs index 417ff67..d97a016 100644 --- a/src/source/routes/common.rs +++ b/src/source/routes/common.rs @@ -277,6 +277,17 @@ impl<'a> Ctx<'a> { /// 계약의 위치(루트 기준 경로, 1 기반 줄, UTF-16 열)다. pub fn locate(&self, loc: &Loc) -> Option { + self.locate_with(loc, |c| c.len_utf16() as u32) + } + + /// 계약의 위치 — 열은 GRAPH-EXCHANGE가 정한 UTF-8 바이트 오프셋 + 1이다. + /// 호출 측 사실(`wrapper.location`)이 이 열을 쓴다. + pub fn locate_utf8(&self, loc: &Loc) -> Option { + self.locate_with(loc, |c| c.len_utf8() as u32) + } + + /// 열 단위(`unit`: 문자 하나의 길이)를 골라 위치를 계산한다. + fn locate_with(&self, loc: &Loc, unit: fn(char) -> u32) -> Option { let start = loc.span.start(); if start.line == 0 { return None; @@ -289,13 +300,8 @@ impl<'a> Ctx<'a> { .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; + // proc-macro2의 열은 문자 수다 — 요청한 단위로 바꾼다. + let column = line_text.chars().take(start.column).map(unit).sum::() + 1; Some(BridgeLocation { path: rel.to_string_lossy().replace('\\', "/"), line: start.line as u32, diff --git a/src/source/routes/compose.rs b/src/source/routes/compose.rs new file mode 100644 index 0000000..a0b7acd --- /dev/null +++ b/src/source/routes/compose.rs @@ -0,0 +1,1021 @@ +//! 클라이언트 URL 조립 — isthmus `url-compose` 규칙(HTTP-WRAPPERS "공통 해석 규칙")의 +//! Rust 구현. +//! +//! syn을 모르는 순수 계층이다. 추출기(`client`)가 문자열 식을 [`Piece`] 목록으로 +//! 바꿔 넘기면, 이 모듈이 라이브러리 의미대로 base와 경로를 결합하고 정규 템플릿· +//! `pathAnchor`·`authority`·`queryTailStripped`·`maskedSegments`·`channelPrefix`를 +//! 확정한다. 공유 벡터(`conformance/url-compose.json`)가 같은 함수로 실행된다. +//! +//! Rust 클라이언트의 결합 방식은 세 갈래다(근거와 오라클 기록은 docs/HTTP-ROUTES.md). +//! +//! - [`Join::WhatwgConcat`]: 문자열을 이어 붙인 뒤 `url::Url::parse`(WHATWG URL +//! Standard)로 해석한다 — reqwest의 `IntoUrl for &str/String`, ureq 2.x. 점 +//! 세그먼트를 지우고 `//`는 보존한다. base 리터럴이면 실제 결과, 미상 base 뒤 +//! `/x`는 base, 상대 경로는 dynamic + `ambiguous-base-join:`(dio 행과 같은 결과). +//! - [`Join::WhatwgJoin`]: `url::Url::join` — WHATWG 상대 해석. http(s)에서는 +//! RFC 3986 병합과 같다(`/x`는 root, `x`는 base 마지막 세그먼트를 바꾼다). +//! - [`Join::HttpUriConcat`]: 문자열을 이어 붙인 뒤 `http::Uri`로 해석한다 — +//! ureq 3.x. 점 세그먼트를 지우지 않는다. + +use super::template::{normalize_uri_path, render, template_problem, Seg}; + +/// 값 조각의 출처 — `http-wrapper-undeclared:` 판정과 `baseRef`에 쓴다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Origin { + /// 출처를 모르는 값. + Unknown, + /// 감싸는 함수의 매개변수(이름) — 선언되지 않은 래퍼 싱크의 표지다. + Param(String), + /// 구조체 필드나 상수·static — 그 생산자 id를 `baseRef`로 싣는다. + Base(String), +} + +/// 문자열 식의 조각 하나다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Piece { + /// 값을 아는 리터럴 조각. + Lit(String), + /// 정적으로 모르는 값(보간·매개변수 등). + Value(Origin), + /// 값이 모두 `?`로 시작하거나 비어 있음을 증명한 지역 변수(`compose.suffix`). + QueryTail, +} + +/// 템플릿이 서버 루트부터 확정됐는지(`root`) 모르는 base 뒤인지(`base`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PathAnchor { + Root, + Base, +} + +impl PathAnchor { + /// 계약 문자열. + pub fn as_str(self) -> &'static str { + match self { + PathAnchor::Root => "root", + PathAnchor::Base => "base", + } + } +} + +/// 확정한 정적 템플릿. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Template { + pub template: String, + pub anchor: PathAnchor, + pub authority: Option, + pub query_tail_stripped: bool, + pub masked_segments: usize, +} + +/// 조립 결과다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// 정적 템플릿. + Template(Template), + /// 템플릿으로 확정하지 못한 호출. `prefix`는 증명한 리터럴 접두사 템플릿(마스킹 + /// 적용), `ambiguous`는 미상 base 뒤 상대 경로라 `ambiguous-base-join:`으로 센다. + Dynamic { + prefix: Option, + anchor: PathAnchor, + ambiguous: bool, + masked_segments: usize, + }, + /// 요청이 될 수 없는 URL — base 없는 상대 URL(`url::Url::parse`가 거부한다). + Unrequestable, +} + +/// 이 모듈이 아는 base 결합 방식이다. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Join { + /// 문자열 연결 후 `url::Url::parse`(reqwest, ureq 2.x). + WhatwgConcat, + /// `url::Url::join`(WHATWG 상대 해석 = http(s)에서 RFC 3986). + WhatwgJoin, + /// 문자열 연결 후 `http::Uri`(ureq 3.x) — 점 세그먼트를 지우지 않는다. + HttpUriConcat, + /// base 끝 `/`와 경로 앞 `/`를 하나로 합친다. Rust 라이브러리 중 이 방식을 쓰는 + /// 것은 모델링하지 않았고, 공유 벡터의 `slash-join` 사례를 위해 둔다. + SlashJoin, +} + +/// 조립 중인 URL 값 — `url::Url`이나 URL 문자열을 해석한 상태다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UrlVal { + /// 소문자 scheme(`http`·`https`) — 모르면 None. + pub scheme: Option, + /// 리터럴 authority(`host[:port]`) — 모르거나 동적이면 None. + pub authority: Option, + pub path: UrlPath, + /// query·fragment를 떼어 냈는가. + pub query: bool, + /// dot 세그먼트를 지우는 해석기인가(`http::Uri`는 지우지 않는다). + pub dots: bool, +} + +/// URL의 경로 상태다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UrlPath { + /// `/`로 시작하는 경로 조각들과 그 앵커. + Known { + anchor: PathAnchor, + pieces: Vec, + }, + /// 경로를 전혀 모르는 base(`Url::parse(미상)`) — 상대 해석만 뒤에 잇는다. + Opaque, + /// 템플릿을 포기한 경로. + Dynamic { + prefix: Option<(PathAnchor, Vec)>, + ambiguous: bool, + }, + /// base 없는 상대 URL — 요청이 되지 않는다. + Unrequestable, +} + +impl UrlVal { + /// 경로·authority를 모두 모르는 URL(매개변수로 받은 `Url` 등). + pub fn opaque(dots: bool) -> UrlVal { + UrlVal { + scheme: None, + authority: None, + path: UrlPath::Opaque, + query: false, + dots, + } + } + + /// 조각을 이어 붙인 문자열을 절대 URL로 해석한다(`Url::parse`·`http::Uri`). + /// + /// 앞 리터럴이 `http(s)://authority`를 모두 담으면 경로는 root다. authority + /// 자리에 값이 끼면 그 값이 경로를 담을 수 있으므로 뒤의 첫 `/`부터 base다. + /// 값으로 시작하면 그 값이 base이고, 뒤 리터럴이 `/`로 시작하지 않으면 base에 + /// 달라붙어 모호하다. 리터럴이 scheme 없이 시작하면 요청이 될 수 없다. + pub fn parse(pieces: &[Piece], dots: bool) -> UrlVal { + let pieces = preprocess(pieces); + let mut out = UrlVal { + scheme: None, + authority: None, + path: UrlPath::Dynamic { + prefix: None, + ambiguous: false, + }, + query: false, + dots, + }; + match pieces.first() { + None => out.path = UrlPath::Unrequestable, + Some(Piece::Lit(first)) => match split_scheme(first) { + Some((scheme, rest)) if scheme == "http" || scheme == "https" => { + out.scheme = Some(scheme); + let rest = rest.trim_start_matches('/').to_string(); + let mut tail = vec![Piece::Lit(rest)]; + tail.extend(pieces[1..].iter().cloned()); + out.parse_authority(&tail); + } + // mailto:·file: 등 http가 아닌 scheme — reqwest·ureq가 보내기 전에 거부한다. + Some(_) => out.path = UrlPath::Unrequestable, + None => out.path = UrlPath::Unrequestable, + }, + Some(_) => { + let rest = &pieces[1..]; + out.path = match rest.first() { + Some(Piece::Lit(l)) if l.starts_with('/') => UrlPath::Known { + anchor: PathAnchor::Base, + pieces: rest.to_vec(), + }, + Some(Piece::Lit(l)) if !l.starts_with(['?', '#']) => UrlPath::Dynamic { + prefix: None, + ambiguous: true, + }, + _ => UrlPath::Dynamic { + prefix: None, + ambiguous: false, + }, + }; + } + } + out.strip_query(); + out + } + + /// `scheme://` 뒤 조각에서 authority와 경로를 가른다. + fn parse_authority(&mut self, tail: &[Piece]) { + let Some(Piece::Lit(head)) = tail.first() else { + return; + }; + if let Some(end) = head.find(['/', '?', '#']) { + self.authority = authority_of(&head[..end]); + let mut pieces = vec![Piece::Lit(head[end..].to_string())]; + pieces.extend(tail[1..].iter().cloned()); + if let Some(Piece::Lit(l)) = pieces.first_mut() { + if l.starts_with(['?', '#']) { + l.insert(0, '/'); + } + } + self.path = UrlPath::Known { + anchor: PathAnchor::Root, + pieces, + }; + return; + } + if tail.len() == 1 { + self.authority = authority_of(head); + self.path = UrlPath::Known { + anchor: PathAnchor::Root, + pieces: vec![Piece::Lit("/".to_string())], + }; + return; + } + // authority 안에 값이 있다 — 계약: host가 동적이면 base다. + for (i, p) in tail.iter().enumerate().skip(1) { + let Piece::Lit(l) = p else { continue }; + if let Some(end) = l.find(['/', '?', '#']) { + if l[end..].starts_with('/') { + let mut pieces = vec![Piece::Lit(l[end..].to_string())]; + pieces.extend(tail[i + 1..].iter().cloned()); + self.path = UrlPath::Known { + anchor: PathAnchor::Base, + pieces, + }; + } + return; + } + } + } + + /// 경로 조각의 첫 `?`·`#`부터 끝까지를 떼고 `query`를 표시한다 — base 경로의 + /// query가 상대 병합의 마지막 `/` 판정에 섞이지 않게 한다. + fn strip_query(&mut self) { + if let UrlPath::Known { pieces, .. } = &mut self.path { + if cut_query(pieces) { + self.query = true; + } + } + } + + /// `url::Url::join`(WHATWG 상대 해석)으로 경로를 잇는다. + pub fn join(&self, input: &[Piece]) -> UrlVal { + let input = preprocess(input); + // 해석에 실패한 base(`Url::parse`가 Err)는 `?`로 빠져 요청이 되지 않는다. + if self.path == UrlPath::Unrequestable { + return self.clone(); + } + let mut out = self.clone(); + out.query = false; + let unknown_base = matches!( + self.path, + UrlPath::Opaque + | UrlPath::Known { + anchor: PathAnchor::Base, + .. + } + ); + let first = match input.first() { + // 빈 참조는 base 경로 그대로다 — base를 모르면 경로를 주장할 수 없다. + None if unknown_base => return out.into_dynamic(true), + None => return out, + Some(Piece::Lit(l)) => l.clone(), + Some(_) => return out.into_dynamic(false), + }; + if let Some((scheme, rest)) = split_scheme(&first) { + let same = self.scheme.as_deref() == Some(scheme.as_str()); + if same && !rest.starts_with('/') && (scheme == "http" || scheme == "https") { + // WHATWG: 같은 special scheme의 `http:x`는 상대 참조다. + let mut rel = vec![Piece::Lit(rest.to_string())]; + rel.extend(input[1..].iter().cloned()); + return self.join(&rel); + } + return UrlVal::parse(&input, self.dots); + } + if first.starts_with("//") { + return match &self.scheme { + Some(s) => { + let mut abs = vec![Piece::Lit(format!("{s}:{first}"))]; + abs.extend(input[1..].iter().cloned()); + UrlVal::parse(&abs, self.dots) + } + None => out.into_dynamic(false), + }; + } + if first.starts_with('/') { + out.path = UrlPath::Known { + anchor: PathAnchor::Root, + pieces: input, + }; + } else if first.starts_with(['?', '#']) || first.is_empty() { + out.query = true; + if matches!(self.path, UrlPath::Opaque) { + return out.into_dynamic(true); + } + } else if unknown_base && has_dot_dot(&input) { + // HTTP-WRAPPERS `rfc3986`, base 미상: `..`는 지울 세그먼트를 알 수 없다. + return out.into_dynamic(true); + } else { + out.path = match &self.path { + UrlPath::Known { anchor, pieces } => UrlPath::Known { + anchor: *anchor, + pieces: merge(pieces, &input), + }, + UrlPath::Opaque => { + let mut pieces = vec![Piece::Lit("/".to_string())]; + pieces.extend(input); + UrlPath::Known { + anchor: PathAnchor::Base, + pieces, + } + } + other => other.clone(), + }; + } + out.strip_query(); + out + } + + /// 경로를 dynamic으로 바꾼다. + fn into_dynamic(mut self, ambiguous: bool) -> UrlVal { + self.path = UrlPath::Dynamic { + prefix: None, + ambiguous, + }; + self + } + + /// 이 URL을 다시 문자열 조각으로 쓴다(`as_str()`·`format!("{url}")`). + pub fn to_pieces(&self) -> Vec { + let mut out = Vec::new(); + match (&self.path, &self.scheme, &self.authority) { + ( + UrlPath::Known { + anchor: PathAnchor::Root, + pieces, + }, + Some(s), + Some(a), + ) => { + out.push(Piece::Lit(format!("{s}://{a}"))); + out.extend(pieces.iter().cloned()); + } + (UrlPath::Known { pieces, .. }, _, _) => { + out.push(Piece::Value(Origin::Unknown)); + out.extend(pieces.iter().cloned()); + } + _ => out.push(Piece::Value(Origin::Unknown)), + } + if self.query { + out.push(Piece::QueryTail); + } + out + } + + /// 계약 사실로 확정한다. + pub fn outcome(&self) -> Outcome { + match &self.path { + UrlPath::Known { anchor, pieces } => { + let authority = match anchor { + PathAnchor::Root => self.authority.clone(), + PathAnchor::Base => None, + }; + let mut out = compose_path(*anchor, pieces, authority.as_deref(), self.dots); + if let Outcome::Template(t) = &mut out { + t.query_tail_stripped |= self.query; + } + out + } + UrlPath::Opaque => Outcome::Dynamic { + prefix: None, + anchor: PathAnchor::Base, + ambiguous: false, + masked_segments: 0, + }, + UrlPath::Dynamic { prefix, ambiguous } => { + let (prefix, anchor, masked) = match prefix { + Some((anchor, pieces)) => { + let (p, m) = prefix_of(pieces, self.authority.as_deref()); + (p, *anchor, m) + } + None => (None, PathAnchor::Base, 0), + }; + Outcome::Dynamic { + prefix, + anchor, + ambiguous: *ambiguous, + masked_segments: masked, + } + } + UrlPath::Unrequestable => Outcome::Unrequestable, + } + } +} + +/// base와 경로를 결합 방식대로 잇는다 — 벡터 러너와 추출기가 같이 쓴다. base가 +/// None이면 미상 base다. +pub fn join(join: Join, base: Option<&str>, path: &[Piece]) -> Outcome { + let dots = join != Join::HttpUriConcat; + match join { + Join::WhatwgJoin => { + let base = match base { + Some(b) => UrlVal::parse(&[Piece::Lit(b.to_string())], dots), + None => UrlVal::opaque(dots), + }; + base.join(path).outcome() + } + Join::WhatwgConcat | Join::HttpUriConcat => { + let mut pieces = vec![match base { + Some(b) => Piece::Lit(b.to_string()), + None => Piece::Value(Origin::Unknown), + }]; + pieces.extend(path.iter().cloned()); + UrlVal::parse(&pieces, dots).outcome() + } + Join::SlashJoin => { + let mut rest = path.to_vec(); + if let Some(Piece::Lit(l)) = rest.first_mut() { + *l = l.trim_start_matches('/').to_string(); + } + match base { + Some(b) => { + let mut pieces = vec![Piece::Lit(format!("{}/", b.trim_end_matches('/')))]; + pieces.extend(rest); + UrlVal::parse(&pieces, dots).outcome() + } + None => { + let mut pieces = vec![Piece::Lit("/".to_string())]; + pieces.extend(rest); + compose_path(PathAnchor::Base, &pieces, None, dots) + } + } + } + } +} + +/// WHATWG 전처리 — 앞뒤 C0·공백을 자르고 탭·줄바꿈을 지우며 `\`를 `/`로 읽는다 +/// (http(s)는 special scheme이다). 빈 리터럴은 버리고 이웃 리터럴은 합친다. +fn preprocess(pieces: &[Piece]) -> Vec { + let mut out: Vec = Vec::new(); + for p in pieces { + match p { + Piece::Lit(l) => { + let cleaned: String = l + .chars() + .filter(|c| !matches!(c, '\t' | '\n' | '\r')) + .map(|c| if c == '\\' { '/' } else { c }) + .collect(); + if let Some(Piece::Lit(prev)) = out.last_mut() { + prev.push_str(&cleaned); + } else if !cleaned.is_empty() { + out.push(Piece::Lit(cleaned)); + } + } + other => out.push(other.clone()), + } + } + let is_c0_or_space = |c: char| c <= ' '; + if let Some(Piece::Lit(l)) = out.first_mut() { + *l = l.trim_start_matches(is_c0_or_space).to_string(); + } + if let Some(Piece::Lit(l)) = out.last_mut() { + *l = l.trim_end_matches(is_c0_or_space).to_string(); + } + out.retain(|p| !matches!(p, Piece::Lit(l) if l.is_empty())); + out +} + +/// 리터럴 조각에 `..`(또는 퍼센트 인코딩 변형) 세그먼트가 있는가. query·fragment 뒤는 보지 않는다. +fn has_dot_dot(pieces: &[Piece]) -> bool { + for p in pieces { + if let Piece::Lit(l) = p { + let path = l.split(['?', '#']).next().unwrap_or(""); + if path.split('/').any(is_double_dot) { + return true; + } + if path.len() != l.len() { + return false; + } + } + } + false +} + +/// `scheme:` 접두사를 떼어 (소문자 scheme, 나머지)로 돌려준다. +fn split_scheme(text: &str) -> Option<(String, &str)> { + let colon = text.find(':')?; + let scheme = &text[..colon]; + let mut chars = scheme.chars(); + let first = chars.next()?; + if !first.is_ascii_alphabetic() + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + Some((scheme.to_ascii_lowercase(), &text[colon + 1..])) +} + +/// 리터럴 authority에서 userinfo를 떼고 소문자 `host[:port]`만 남긴다. 계약이 +/// 받는 모양(ASCII host 문자, IPv6 괄호, 숫자 port)이 아니면 싣지 않는다. +fn authority_of(raw: &str) -> Option { + let host_port = raw.rsplit_once('@').map_or(raw, |(_, h)| h); + let lower = host_port.to_ascii_lowercase(); + let (host, port) = if lower.starts_with('[') { + let close = lower.find(']')?; + let rest = &lower[close + 1..]; + (&lower[..=close], rest.strip_prefix(':')) + } else { + match lower.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (lower.as_str(), None), + } + }; + // WHATWG: 빈 포트(`h:`)는 포트 없음이다(url 2.5.8 실행 확인). + let port = port.filter(|p| !p.is_empty()); + let host_ok = if host.starts_with('[') { + host[1..host.len() - 1] + .chars() + .all(|c| c.is_ascii_hexdigit() || c == ':' || c == '.') + } else { + !host.is_empty() + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')) + }; + let port_ok = port.is_none_or(|p| p.chars().all(|c| c.is_ascii_digit())); + (host_ok && port_ok).then(|| match port { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }) +} + +/// 조각의 첫 `?`·`#`부터 끝까지 뗀다. 뗐으면 true. +fn cut_query(pieces: &mut Vec) -> bool { + for i in 0..pieces.len() { + if let Piece::Lit(l) = &pieces[i] { + if let Some(at) = l.find(['?', '#']) { + let head = l[..at].to_string(); + pieces.truncate(i); + if !head.is_empty() { + pieces.push(Piece::Lit(head)); + } + return true; + } + } + } + false +} + +/// RFC 3986 병합 — base 경로의 마지막 `/`까지 남기고 상대 경로를 붙인다. +fn merge(base: &[Piece], rel: &[Piece]) -> Vec { + let mut out: Vec = Vec::new(); + for (i, p) in base.iter().enumerate().rev() { + if let Piece::Lit(l) = p { + if let Some(at) = l.rfind('/') { + out.extend(base[..i].iter().cloned()); + out.push(Piece::Lit(l[..=at].to_string())); + break; + } + } + } + if out.is_empty() { + out.push(Piece::Lit("/".to_string())); + } + out.extend(rel.iter().cloned()); + out +} + +/// 경로 세그먼트 하나 — 리터럴 또는 세그먼트 전체 보간. +#[derive(Clone, Debug, PartialEq, Eq)] +enum PSeg { + Lit(String), + Param, +} + +/// 경로 조각을 정규 템플릿으로 조립한다(`compose.*` 규칙). +/// +/// 1. 첫 `?`·`#`부터 뗀다(`compose.query-tail`). 끝의 [`Piece::QueryTail`]도 뗀다 +/// (`compose.suffix`) — 중간에 오면 보통 값이다. +/// 2. 값은 세그먼트 전체를 채울 때만 `{}`다(`compose.interpolation`). 아니면 그 +/// 값 앞까지의 조립 결과를 `channelPrefix`로 싣는 dynamic이다. +/// 3. `dots`면 점 세그먼트를 지운다. base 앵커에서 알려진 경로 위로 올라가면 +/// dynamic이다. +/// 4. 정규화(`compose.normalize`)·마스킹(`compose.mask`)을 적용한다. +pub fn compose_path( + anchor: PathAnchor, + pieces: &[Piece], + authority: Option<&str>, + dots: bool, +) -> Outcome { + let mut pieces = preprocess_path(pieces); + let mut query = cut_query(&mut pieces); + if matches!(pieces.last(), Some(Piece::QueryTail)) { + pieces.pop(); + query = true; + } + let dynamic = |prefix: Option, masked: usize| Outcome::Dynamic { + prefix, + anchor, + ambiguous: false, + masked_segments: masked, + }; + match pieces.first() { + Some(Piece::Lit(l)) if l.starts_with('/') => {} + _ => return dynamic(None, 0), + } + let segs = match segments(&pieces) { + Ok(segs) => segs, + Err(prefix_pieces) => { + let (prefix, masked) = prefix_of(&prefix_pieces, authority); + return dynamic(prefix, masked); + } + }; + let segs = if dots { + match remove_dots(segs, anchor) { + Some(s) => s, + None => return dynamic(None, 0), + } + } else { + segs + }; + let mut rendered = to_template_segs(&segs); + let masked = mask(authority, &mut rendered); + let template = render(&rendered); + if template_problem(&template).is_some() { + return dynamic(None, 0); + } + Outcome::Template(Template { + template, + anchor, + authority: authority.map(str::to_string), + query_tail_stripped: query, + masked_segments: masked, + }) +} + +/// 경로 조각의 리터럴을 합친다(빈 리터럴 제거). `\`는 이미 전처리됐다고 본다. +fn preprocess_path(pieces: &[Piece]) -> Vec { + let mut out: Vec = Vec::new(); + for p in pieces { + match (p, out.last_mut()) { + (Piece::Lit(l), Some(Piece::Lit(prev))) => prev.push_str(l), + (Piece::Lit(l), _) if l.is_empty() => {} + (other, _) => out.push(other.clone()), + } + } + out +} + +/// 조각을 세그먼트로 나눈다. 세그먼트 일부만 채우는 값을 만나면 그 값 앞까지의 +/// 조각(접두사 후보)을 Err로 돌려준다. +/// +/// 호출자는 첫 조각이 `/`로 시작하는 리터럴임을 보장한다. +fn segments(pieces: &[Piece]) -> Result, Vec> { + let mut segs: Vec = Vec::new(); + // 지금 채우는 세그먼트 — 빈 리터럴이면 막 `/`를 지난 자리다. + let mut cur = PSeg::Lit(String::new()); + for (i, p) in pieces.iter().enumerate() { + match p { + Piece::Lit(l) => { + let body = if i == 0 { &l[1..] } else { l.as_str() }; + for (j, part) in body.split('/').enumerate() { + if j > 0 { + segs.push(std::mem::replace(&mut cur, PSeg::Lit(String::new()))); + } + if part.is_empty() { + continue; + } + match &mut cur { + PSeg::Lit(text) => text.push_str(part), + // 값 바로 뒤에 `/` 없는 리터럴 — 아래 값 검사가 먼저 막는다. + PSeg::Param => return Err(pieces[..i].to_vec()), + } + } + } + Piece::Value(_) | Piece::QueryTail => { + let starts_segment = cur == PSeg::Lit(String::new()); + let ends_segment = match pieces.get(i + 1) { + None => true, + Some(Piece::Lit(next)) => next.starts_with('/'), + Some(_) => false, + }; + if !(starts_segment && ends_segment) { + return Err(pieces[..i].to_vec()); + } + cur = PSeg::Param; + } + } + } + segs.push(cur); + Ok(segs) +} + +/// WHATWG 점 세그먼트 제거(`.`·`..`와 `%2e` 변형). 끝 세그먼트가 점이면 빈 +/// 세그먼트(끝 슬래시)를 남긴다. base 앵커에서 첫 세그먼트 위로 오르면 None. +fn remove_dots(segs: Vec, anchor: PathAnchor) -> Option> { + let n = segs.len(); + let mut out: Vec = Vec::new(); + for (i, s) in segs.into_iter().enumerate() { + let last = i + 1 == n; + match &s { + PSeg::Lit(t) if is_single_dot(t) => { + if last { + out.push(PSeg::Lit(String::new())); + } + } + PSeg::Lit(t) if is_double_dot(t) => { + if out.pop().is_none() && anchor == PathAnchor::Base { + return None; + } + if last { + out.push(PSeg::Lit(String::new())); + } + } + _ => out.push(s), + } + } + if out.is_empty() { + out.push(PSeg::Lit(String::new())); + } + Some(out) +} + +/// `.` 또는 `%2e`(대소문자 무관). +fn is_single_dot(s: &str) -> bool { + s == "." || s.eq_ignore_ascii_case("%2e") +} + +/// `..`와 그 퍼센트 인코딩 변형. +fn is_double_dot(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + matches!(lower.as_str(), ".." | ".%2e" | "%2e." | "%2e%2e") +} + +/// 경로 세그먼트를 템플릿 세그먼트로 바꾼다. +fn to_template_segs(segs: &[PSeg]) -> Vec { + segs.iter() + .map(|s| match s { + PSeg::Lit(t) => Seg::Lit(t.clone()), + PSeg::Param => Seg::Param { + prefix: String::new(), + suffix: String::new(), + constraint: None, + }, + }) + .collect() +} + +/// 접두사 조각을 마스킹한 정규 템플릿으로 쓴다. `/`로 시작하지 않으면 None. +fn prefix_of(pieces: &[Piece], authority: Option<&str>) -> (Option, usize) { + let pieces = preprocess_path(pieces); + match pieces.first() { + Some(Piece::Lit(l)) if l.starts_with('/') => {} + _ => return (None, 0), + } + let Ok(segs) = segments(&pieces) else { + return (None, 0); + }; + let mut rendered = to_template_segs(&segs); + let masked = mask(authority, &mut rendered); + let text = render(&rendered); + if template_problem(&text).is_some() { + return (None, 0); + } + (Some(text), masked) +} + +/// 알려진 웹훅 host와 고엔트로피 리터럴 세그먼트를 `{}`로 바꾸고 바꾼 수를 센다. +pub fn mask(authority: Option<&str>, segs: &mut [Seg]) -> usize { + let host = authority.map(|a| { + let h = a.rsplit_once(':').map_or(a, |(h, _)| h); + h.to_ascii_lowercase() + }); + let webhook_from = match host.as_deref() { + Some("hooks.slack.com") => Some(0), + Some("discord.com" | "discordapp.com") => { + let lit = |i: usize, want: &str| matches!(segs.get(i), Some(Seg::Lit(t)) if t == want); + (lit(0, "api") && lit(1, "webhooks")).then_some(2) + } + _ => None, + }; + let mut count = 0; + for (i, seg) in segs.iter_mut().enumerate() { + let Seg::Lit(text) = seg else { continue }; + let webhook = webhook_from.is_some_and(|from| i >= from) && !text.is_empty(); + if webhook || is_high_entropy(text) { + *seg = Seg::Param { + prefix: String::new(), + suffix: String::new(), + constraint: None, + }; + count += 1; + } + } + count +} + +/// 퍼센트 디코드한 값이 16자 이상이고 ASCII 글자와 숫자를 모두 담는가. +fn is_high_entropy(text: &str) -> bool { + let decoded = percent_decode(&normalize_uri_path(text)); + decoded.chars().count() >= 16 + && decoded.chars().any(|c| c.is_ascii_alphabetic()) + && decoded.chars().any(|c| c.is_ascii_digit()) +} + +/// `%XX`를 바이트로 풀어 UTF-8(손실 허용)로 읽는다. +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + // 바이트로 검사한다 — 문자열 슬라이스는 다중 바이트 문자 경계에서 패닉한다. + if bytes[i] == b'%' + && i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit() + { + let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8; + out.push(hex(bytes[i + 1]) * 16 + hex(bytes[i + 2])); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// 정규 템플릿 문자열을 세그먼트로 읽는다(`{}`만 파라미터) — 벡터의 `compose.mask` +/// 입력용이다. +pub fn parse_template(template: &str) -> Vec { + template + .strip_prefix('/') + .unwrap_or(template) + .split('/') + .map(|s| { + if s == "{}" { + Seg::Param { + prefix: String::new(), + suffix: String::new(), + constraint: None, + } + } else { + Seg::Lit(s.to_string()) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lit(s: &str) -> Piece { + Piece::Lit(s.to_string()) + } + + fn val() -> Piece { + Piece::Value(Origin::Unknown) + } + + fn tpl(o: &Outcome) -> (&str, PathAnchor) { + match o { + Outcome::Template(t) => (t.template.as_str(), t.anchor), + other => panic!("not a template: {other:?}"), + } + } + + #[test] + fn whatwg_join_matches_url_crate() { + // 기대값은 url 2.5.8 `Url::join` 실행 결과다(docs/HTTP-ROUTES.md). + let cases = [ + ("http://h/api", "x", "/x"), + ("http://h/api/", "x", "/api/x"), + ("http://h/a/b/c", "../x", "/a/x"), + ("http://h/api/", "a/./b/../c", "/api/a/c"), + ("http://h/api", "\\x", "/x"), + ("http://h/api/", "x//y", "/api/x//y"), + ("http://h/api/", "http:x", "/api/x"), + ("http://h/api", "?q=1", "/api"), + ("http://h", "x", "/x"), + ]; + for (base, path, want) in cases { + let o = join(Join::WhatwgJoin, Some(base), &[lit(path)]); + assert_eq!(tpl(&o), (want, PathAnchor::Root), "{base} + {path}"); + } + let other = join(Join::WhatwgJoin, Some("http://h/api"), &[lit("//Other/x")]); + match other { + Outcome::Template(t) => { + assert_eq!(t.template, "/x"); + assert_eq!(t.authority.as_deref(), Some("other")); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn opaque_base_relative_and_dots() { + let o = join(Join::WhatwgJoin, None, &[lit("a/b")]); + assert_eq!(tpl(&o), ("/a/b", PathAnchor::Base)); + let up = join(Join::WhatwgJoin, None, &[lit("a/../x")]); + assert!( + matches!( + up, + Outcome::Dynamic { + ambiguous: true, + .. + } + ), + "{up:?}" + ); + let q = join(Join::WhatwgJoin, None, &[lit("?x")]); + assert!(matches!( + q, + Outcome::Dynamic { + ambiguous: true, + .. + } + )); + let empty = UrlVal::opaque(true).join(&[]).outcome(); + assert!(matches!( + empty, + Outcome::Dynamic { + ambiguous: true, + .. + } + )); + let known_empty = UrlVal::parse(&[lit("http://h/a")], true) + .join(&[]) + .outcome(); + assert!(matches!(known_empty, Outcome::Template(_))); + let query_dots = join(Join::WhatwgJoin, None, &[lit("x?next=../y")]); + assert_eq!(tpl(&query_dots), ("/x", PathAnchor::Base)); + // 두 번 잇기 — 미상 base 뒤 `api/` + `users`. + let u = UrlVal::opaque(true) + .join(&[lit("api/")]) + .join(&[lit("users")]); + assert_eq!(tpl(&u.outcome()), ("/api/users", PathAnchor::Base)); + } + + #[test] + fn concat_parses_like_url_parse() { + let o = join(Join::WhatwgConcat, Some("http://h/a/../b"), &[lit("/c")]); + assert_eq!(tpl(&o), ("/b/c", PathAnchor::Root)); + let keep = join(Join::HttpUriConcat, Some("http://h/a/../b"), &[lit("/c")]); + assert_eq!(tpl(&keep), ("/a/../b/c", PathAnchor::Root)); + let dynamic_host = UrlVal::parse(&[lit("http://"), val(), lit("/users/"), val()], true); + assert_eq!( + tpl(&dynamic_host.outcome()), + ("/users/{}", PathAnchor::Base) + ); + let relative = UrlVal::parse(&[lit("/users")], true); + assert_eq!(relative.outcome(), Outcome::Unrequestable); + let glued = UrlVal::parse(&[val(), lit("users")], true); + assert!(matches!( + glued.outcome(), + Outcome::Dynamic { + ambiguous: true, + .. + } + )); + let ftp = UrlVal::parse(&[lit("ftp://h/x")], true); + assert_eq!(ftp.outcome(), Outcome::Unrequestable); + assert_eq!( + relative.join(&[lit("/x")]).outcome(), + Outcome::Unrequestable + ); + let query_only = UrlVal::parse(&[lit("https://h?x=1")], true); + match query_only.outcome() { + Outcome::Template(t) => { + assert_eq!(t.template, "/"); + assert!(t.query_tail_stripped); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn authorities_are_validated() { + assert_eq!( + authority_of("u:p@API.Example.com:8080").as_deref(), + Some("api.example.com:8080") + ); + assert_eq!(authority_of("[::1]:3000").as_deref(), Some("[::1]:3000")); + assert_eq!(authority_of("caf\u{e9}.com"), None); + // 밑줄 host와 빈 포트는 WHATWG가 받는다(url 2.5.8: `my_api.example.com`, `h:` → 포트 없음). + assert_eq!(authority_of("My_Api.test").as_deref(), Some("my_api.test")); + assert_eq!(authority_of("h:").as_deref(), Some("h")); + assert_eq!(authority_of("h:x1"), None); + } + + #[test] + fn url_round_trips_through_pieces() { + let u = UrlVal::parse(&[lit("http://h/v1/")], true).join(&[lit("items")]); + let again = UrlVal::parse(&u.to_pieces(), true); + assert_eq!(tpl(&again.outcome()), ("/v1/items", PathAnchor::Root)); + let opaque = UrlVal::opaque(true).to_pieces(); + assert_eq!(opaque, vec![val()]); + } + + #[test] + fn percent_decode_handles_edges() { + assert_eq!(percent_decode("a%41"), "aA"); + assert_eq!(percent_decode("a%4"), "a%4"); + assert_eq!(percent_decode("%zz"), "%zz"); + } +} diff --git a/src/source/routes/wrappers.rs b/src/source/routes/wrappers.rs new file mode 100644 index 0000000..75c56fe --- /dev/null +++ b/src/source/routes/wrappers.rs @@ -0,0 +1,326 @@ +//! isthmus `http-wrappers` v1 선언 — 파싱과 동사·인자 바인딩(`wrapper.*` 규칙). +//! +//! 정본은 isthmus docs/HTTP-WRAPPERS.md다. 모르는 필드·잘못된 값은 선언 오류다 — +//! 낡은 선언을 조용히 무시하면 호출 0건이 "호출 없음"으로 읽힌다. 이 모듈은 syn을 +//! 모른다. 추출기가 호출 인자를 [`CallArg`]로 바꿔 넘긴다. +//! +//! Rust 이름 규칙(`"language": "rust"` 항목만 적용): +//! +//! - `function`: `owner::name`이 rustograph 정점 ID다. 자유 함수는 모듈 경로 +//! (`app::net` + `send`), 메서드·연관 함수는 타입 ID(`app::api::ApiClient` + +//! `request`), 트레이트 impl 메서드는 `app::api::ApiClient::` + 이름. +//! - `constructor`: `owner`는 타입 ID다. `name`이 타입 이름과 같으면 구조체 리터럴 +//! (`Endpoint { method, path }` — `label`은 필드 이름)이나 튜플 구조체 생성 +//! (`Endpoint(m, p)` — `index`)이고, 다르면 연관 함수(`Endpoint::new`)다. +//! - 인자: Rust 함수에는 이름 붙은 인자가 없으므로 함수·메서드는 `index`(메서드는 +//! 수신자를 빼고 0부터)로 묶는다. `label`은 구조체 리터럴 필드에만 맞는다. + +use serde_json::Value; +use std::collections::BTreeMap; + +/// 계약 동사. +pub const VERBS: &[&str] = &[ + "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", +]; + +/// 동사 인자·경로 인자 위치 지정. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ArgSpec { + pub index: Option, + pub label: Option, +} + +/// 래퍼 선언의 종류. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WrapperKind { + Constructor, + Function, +} + +/// 래퍼 선언 하나. +#[derive(Clone, Debug)] +pub struct Wrapper { + /// 선언 파일의 `wrappers[n]` 위치 — 한계 문구에 쓴다. + pub position: usize, + pub language: String, + pub kind: WrapperKind, + pub owner: String, + pub name: String, + pub method_arg: Option, + pub path_arg: ArgSpec, + pub default_method: Option, + pub method_enum: BTreeMap, + /// `root`·`base`. + pub path_anchor: String, + pub service: Option, +} + +impl Wrapper { + /// 동사 바인딩 명세. + pub fn method_spec(&self) -> MethodSpec { + MethodSpec { + method_arg: self.method_arg.clone(), + default_method: self.default_method.clone(), + method_enum: self.method_enum.clone(), + } + } +} + +/// 동사를 정하는 선언 부분 — 벡터(`wrapper.method`)가 이 형태만 준다. +#[derive(Clone, Debug, Default)] +pub struct MethodSpec { + pub method_arg: Option, + pub default_method: Option, + pub method_enum: BTreeMap, +} + +/// 호출 인자 값의 모양. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArgValue { + /// 문자열 리터럴(상수 치환 포함). + Literal(String), + /// enum case·연관 상수 경로의 마지막 이름(`Method::GET` → `GET`). + EnumCase(String), + /// 그 밖의 식. + Other, +} + +/// 호출 인자 하나 — 구조체 리터럴 필드면 `label`이 있다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CallArg { + pub label: Option, + pub value: ArgValue, +} + +/// 인자 찾기(`wrapper.method` 1): `label`이 같은 인자를 먼저, 없으면 `index` 위치의 +/// 인자를 쓰되 그 인자가 다른 레이블을 달고 있으면 쓰지 않는다. +pub fn find_arg<'a>(spec: &ArgSpec, args: &'a [CallArg]) -> Option<&'a CallArg> { + if let Some(label) = &spec.label { + if let Some(a) = args.iter().find(|a| a.label.as_ref() == Some(label)) { + return Some(a); + } + } + let a = args.get(spec.index?)?; + match &a.label { + Some(l) if Some(l) != spec.label.as_ref() => None, + _ => Some(a), + } +} + +/// 동사를 정한다 — None이면 `methodDynamic`이다(`wrapper.method` 2·3). +/// +/// 인자가 없으면 `defaultMethod`, 인자가 있으면 정확한 대문자 동사 리터럴이나 +/// `methodEnum`에 매핑된 enum case·리터럴만 동사다. 리터럴이 아닌 식은 기본값을 +/// 쓰지 않는다. +pub fn bind_method(spec: &MethodSpec, args: &[CallArg]) -> Option { + let found = spec.method_arg.as_ref().and_then(|m| find_arg(m, args)); + let Some(arg) = found else { + return spec.default_method.clone(); + }; + match &arg.value { + ArgValue::Literal(s) if VERBS.contains(&s.as_str()) => Some(s.clone()), + ArgValue::Literal(s) | ArgValue::EnumCase(s) => spec.method_enum.get(s).cloned(), + ArgValue::Other => None, + } +} + +/// 선언 파일을 읽는다. 오류 문구는 원인과 고칠 자리를 담는다. +pub fn parse(text: &str) -> Result, String> { + let root: Value = serde_json::from_str(text) + .map_err(|e| format!("the http-wrappers file is not valid JSON ({e})"))?; + let obj = root + .as_object() + .ok_or("the http-wrappers file must be a JSON object")?; + for key in obj.keys() { + if !matches!(key.as_str(), "format" | "version" | "wrappers") { + return Err(format!( + "unknown field {key:?} in the http-wrappers file (allowed: format, version, wrappers)" + )); + } + } + if obj.get("format").and_then(Value::as_str) != Some("http-wrappers") { + return Err("the http-wrappers file must declare \"format\": \"http-wrappers\"".into()); + } + if obj.get("version").and_then(Value::as_u64) != Some(1) { + return Err("only http-wrappers \"version\": 1 is supported".into()); + } + let list = obj + .get("wrappers") + .and_then(Value::as_array) + .ok_or("the http-wrappers file needs a \"wrappers\" array")?; + list.iter() + .enumerate() + .map(|(i, w)| parse_wrapper(i, w).map_err(|e| format!("wrappers[{i}]: {e}"))) + .collect() +} + +/// 선언 항목 하나를 읽는다. +fn parse_wrapper(position: usize, w: &Value) -> Result { + const FIELDS: &[&str] = &[ + "language", + "kind", + "owner", + "name", + "methodArg", + "pathArg", + "defaultMethod", + "methodEnum", + "pathAnchor", + "service", + ]; + let obj = w.as_object().ok_or("each wrapper must be a JSON object")?; + if let Some(bad) = obj.keys().find(|k| !FIELDS.contains(&k.as_str())) { + return Err(format!("unknown field {bad:?}")); + } + let text = |key: &str| -> Result { + obj.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("{key:?} must be a non-empty string")) + }; + let kind = match text("kind")?.as_str() { + "constructor" => WrapperKind::Constructor, + "function" => WrapperKind::Function, + other => { + return Err(format!( + "\"kind\" must be constructor or function, not {other:?}" + )) + } + }; + let method_arg = obj.get("methodArg").map(arg_spec).transpose()?; + let path_arg = arg_spec(obj.get("pathArg").ok_or("\"pathArg\" is required")?)?; + let default_method = obj.get("defaultMethod").map(verb).transpose()?; + if method_arg.is_none() && default_method.is_none() { + return Err("declare \"methodArg\" or \"defaultMethod\" (a wrapper needs a verb)".into()); + } + let method_enum = match obj.get("methodEnum") { + None => BTreeMap::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| Ok((k.clone(), verb(v)?))) + .collect::>()?, + Some(_) => return Err("\"methodEnum\" must be an object of case → verb".into()), + }; + let path_anchor = text("pathAnchor")?; + if path_anchor != "root" && path_anchor != "base" { + return Err(format!( + "\"pathAnchor\" must be root or base, not {path_anchor:?}" + )); + } + let service = match obj.get("service") { + None => None, + Some(_) => Some(text("service")?), + }; + Ok(Wrapper { + position, + language: text("language")?, + kind, + owner: text("owner")?, + name: text("name")?, + method_arg, + path_arg, + default_method, + method_enum, + path_anchor, + service, + }) +} + +/// `{ index }`·`{ label }`·둘 다. +fn arg_spec(v: &Value) -> Result { + let obj = v + .as_object() + .ok_or("an argument spec must be an object with index and/or label")?; + if let Some(bad) = obj.keys().find(|k| *k != "index" && *k != "label") { + return Err(format!("unknown argument spec field {bad:?}")); + } + let index = match obj.get("index") { + None => None, + Some(i) => Some( + i.as_u64() + .ok_or("\"index\" must be a non-negative integer")? as usize, + ), + }; + let label = match obj.get("label") { + None => None, + Some(l) => Some( + l.as_str() + .filter(|s| !s.is_empty()) + .ok_or("\"label\" must be a non-empty string")? + .to_string(), + ), + }; + if index.is_none() && label.is_none() { + return Err("an argument spec needs index or label".into()); + } + Ok(ArgSpec { index, label }) +} + +/// 계약 동사 문자열. +fn verb(v: &Value) -> Result { + match v.as_str() { + Some(s) if VERBS.contains(&s) => Ok(s.to_string()), + _ => Err(format!("{v} is not a contract verb ({})", VERBS.join(", "))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn declaration_errors_name_the_cause() { + let bad = [ + (r#"[]"#, "JSON object"), + (r#"{"format":"x","version":1,"wrappers":[]}"#, "format"), + ( + r#"{"format":"http-wrappers","version":2,"wrappers":[]}"#, + "version", + ), + (r#"{"format":"http-wrappers","version":1}"#, "wrappers"), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[],"x":1}"#, + "unknown field", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"pathAnchor":"root"}]}"#, + "methodArg", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{},"defaultMethod":"GET","pathAnchor":"root"}]}"#, + "index or label", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"method","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}]}"#, + "kind", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"get","pathAnchor":"root"}]}"#, + "contract verb", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"top"}]}"#, + "pathAnchor", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root","extra":1}]}"#, + "unknown field", + ), + ]; + for (text, needle) in bad { + let err = parse(text).expect_err(text); + assert!(err.contains(needle), "{err} should mention {needle}"); + } + } + + #[test] + fn full_declaration_parses() { + let text = r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"constructor","owner":"app::Endpoint","name":"Endpoint","methodArg":{"index":0,"label":"method"},"pathArg":{"label":"path"},"methodEnum":{"Get":"GET"},"pathAnchor":"base","service":"api"}]}"#; + let w = &parse(text).unwrap()[0]; + assert_eq!(w.kind, WrapperKind::Constructor); + assert_eq!(w.method_enum.get("Get").map(String::as_str), Some("GET")); + assert_eq!(w.service.as_deref(), Some("api")); + assert_eq!(w.method_spec().method_arg.unwrap().index, Some(0)); + } +} diff --git a/tests/client_routes.rs b/tests/client_routes.rs new file mode 100644 index 0000000..25ae0b4 --- /dev/null +++ b/tests/client_routes.rs @@ -0,0 +1,799 @@ +//! `rustograph routes --role client` — isthmus url-compose 벡터, 오라클 기록 대조, 추출 규칙. + +use rustograph::source::routes::client::{client_routes, ClientOptions}; +use rustograph::source::routes::compose::{ + compose_path, join, mask, parse_template, Join, Origin, Outcome, PathAnchor, Piece, UrlVal, +}; +use rustograph::source::routes::template::render; +use rustograph::source::routes::wrappers::{self, ArgSpec, ArgValue, CallArg, MethodSpec}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +fn repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn load(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("readable")).expect("json") +} + +/// 워크스페이스의 클라이언트 문서(JSON). +fn doc_of(dir: &Path, wrappers_json: Option<&str>) -> Value { + let wrappers = wrappers_json + .map(|t| wrappers::parse(t).expect("wrappers parse")) + .unwrap_or_default(); + let opts = ClientOptions { + wrappers, + service: None, + }; + let d = client_routes(dir, "test", &opts).expect("client extraction"); + serde_json::to_value(&d).expect("serializes") +} + +// ── 공유 벡터(url-compose) ───────────────────────────────── + +fn url_compose_cases() -> Vec { + let suite = load(&repo().join("conformance/url-compose.json")); + suite["cases"] + .as_array() + .expect("cases") + .iter() + .filter(|c| { + c["appliesTo"].as_array().is_some_and(|a| { + a.iter() + .any(|t| t == "producer" || t == "producer:rustograph") + }) + }) + .cloned() + .collect() +} + +fn pieces_of(parts: &Value) -> Vec { + parts + .as_array() + .expect("parts") + .iter() + .map(|p| { + if let Some(l) = p["literal"].as_str() { + Piece::Lit(l.to_string()) + } else if p.get("queryTail").is_some() { + Piece::QueryTail + } else { + Piece::Value(Origin::Unknown) + } + }) + .collect() +} + +/// 벡터의 결합 이름 → Rust 결합. `rfc3986`은 isthmus가 Rust `Url::join`에 정한 이름이다. +/// 공통 `dio-concat` 사례는 같은 입력에서 결과가 같은 WHATWG 문자열 연결로 실행한다(점 +/// 세그먼트·`//` 처리만 다르고 벡터 입력에는 없다). +fn join_of(name: &str) -> Join { + match name { + "rfc3986" => Join::WhatwgJoin, + "slash-join" => Join::SlashJoin, + "dio-concat" => Join::WhatwgConcat, + other => panic!("unclassified join {other}"), + } +} + +fn check_outcome(id: &str, c: &Value, out: &Outcome) { + let expect = &c["expect"]; + if c["expectDynamic"] == true { + let Outcome::Dynamic { + prefix, ambiguous, .. + } = out + else { + panic!("{id}: expected dynamic, got {out:?}"); + }; + if let Some(p) = expect.get("channelPrefix") { + assert_eq!(prefix.as_deref(), p.as_str(), "{id} channelPrefix"); + } + if c["expectLimitation"] == "ambiguous-base-join:" { + assert!(ambiguous, "{id}: ambiguous-base-join expected"); + } + return; + } + let Outcome::Template(t) = out else { + panic!("{id}: expected template, got {out:?}"); + }; + assert_eq!( + Some(t.template.as_str()), + expect["template"].as_str(), + "{id}" + ); + if let Some(a) = expect["pathAnchor"].as_str() { + assert_eq!(t.anchor.as_str(), a, "{id} pathAnchor"); + } + if let Some(a) = expect.get("authority") { + assert_eq!(t.authority.as_deref(), a.as_str(), "{id} authority"); + } + assert_eq!( + t.query_tail_stripped, + expect["queryTailStripped"] == true, + "{id} queryTailStripped" + ); + if let Some(m) = expect["maskedSegments"].as_u64() { + assert_eq!(t.masked_segments as u64, m, "{id} maskedSegments"); + } +} + +fn arg_spec(v: &Value) -> Option { + v.as_object().map(|o| ArgSpec { + index: o.get("index").and_then(Value::as_u64).map(|i| i as usize), + label: o.get("label").and_then(Value::as_str).map(str::to_string), + }) +} + +fn run_wrapper_method(id: &str, c: &Value) { + let d = &c["input"]["declaration"]; + let spec = MethodSpec { + method_arg: arg_spec(&d["methodArg"]), + default_method: d["defaultMethod"].as_str().map(str::to_string), + method_enum: d["methodEnum"] + .as_object() + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect() + }) + .unwrap_or_default(), + }; + let args: Vec = c["input"]["call"]["args"] + .as_array() + .unwrap() + .iter() + .map(|a| CallArg { + label: a["label"].as_str().map(str::to_string), + value: if let Some(l) = a["value"]["literal"].as_str() { + ArgValue::Literal(l.to_string()) + } else if let Some(e) = a["value"]["enumCase"].as_str() { + ArgValue::EnumCase(e.to_string()) + } else { + ArgValue::Other + }, + }) + .collect(); + let method = wrappers::bind_method(&spec, &args); + if c["expectDynamic"] == true { + assert_eq!(method, None, "{id}"); + } else { + assert_eq!(method.as_deref(), c["expect"]["method"].as_str(), "{id}"); + } +} + +#[test] +fn url_compose_producer_cases_pass() { + let cases = url_compose_cases(); + // 조용히 줄지 않게 — 벡터 재벤더링 때 수를 확인한다(producer 41 + producer:rustograph 7, 다른 생산자 전용 제외). + assert_eq!(cases.len(), 48); + let mut by_rule: BTreeMap = BTreeMap::new(); + for c in &cases { + let id = c["id"].as_str().unwrap_or(""); + let input = &c["input"]; + let rule = c["ruleId"].as_str().unwrap_or(""); + *by_rule.entry(rule.to_string()).or_default() += 1; + match rule { + "compose.interpolation" + | "compose.query-tail" + | "compose.suffix" + | "compose.normalize" => { + let out = compose_path(PathAnchor::Root, &pieces_of(&input["parts"]), None, true); + check_outcome(id, c, &out); + } + "compose.base-join" => { + let path = [Piece::Lit(input["path"].as_str().unwrap().to_string())]; + let out = join( + join_of(input["join"].as_str().unwrap()), + input["base"].as_str(), + &path, + ); + check_outcome(id, c, &out); + } + "compose.strip" => { + let url = [Piece::Lit(input["url"].as_str().unwrap().to_string())]; + check_outcome(id, c, &UrlVal::parse(&url, true).outcome()); + } + "compose.mask" => { + let mut segs = parse_template(input["template"].as_str().unwrap()); + let n = mask(input["authority"].as_str(), &mut segs); + assert_eq!( + Some(render(&segs).as_str()), + c["expect"]["template"].as_str(), + "{id}" + ); + assert_eq!( + Some(n as u64), + c["expect"]["maskedSegments"].as_u64(), + "{id}" + ); + } + "wrapper.method" => run_wrapper_method(id, c), + // 실제 스캐너로 확인한다 — `wrapper_location_is_the_call_start_line`. + "wrapper.location" => { + assert_eq!(c["expect"]["line"], input["callStartLine"], "{id}"); + } + other => panic!("unclassified url-compose rule {other} ({id})"), + } + } + assert_eq!(by_rule.get("compose.base-join"), Some(&14)); +} + +/// `wrapper.location`: 여러 줄 호출은 호출식이 시작하는 줄이다. 열은 UTF-8 바이트다. +#[test] +fn wrapper_location_is_the_call_start_line() { + let src = r#" +pub fn send(method: &str, path: &str) {} +pub fn caller() { + /* 한글 */ send( + "GET", + "/x", + ); +} +"#; + let dir = temp_crate("location", &[], &[("lib.rs", src)]); + let wrappers = r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"app","name":"send","methodArg":{"index":0},"pathArg":{"index":1},"pathAnchor":"root"}]}"#; + let doc = doc_of(&dir, Some(wrappers)); + let f = &doc["facts"][0]; + assert_eq!(f["location"]["line"], 4); + // ` /* 한글 */ ` = 4 + 3 + 6(한글 UTF-8) + 4 = 17바이트 → 열 18. + assert_eq!(f["location"]["column"], 18); + assert_eq!(f["method"], "GET"); + assert_eq!(f["channel"], "/x"); + assert_eq!(f["pathAnchor"], "root"); +} + +// ── 오라클 기록 대조 ──────────────────────────────────────── + +/// 오라클 요약과 같은 필드만 남긴다. +fn summary(f: &Value) -> Value { + let mut out = json!({ + "channel": f["channel"], + "dynamic": f["dynamic"], + "pathAnchor": f["pathAnchor"], + "usr": f.pointer("/symbol/usr").cloned().unwrap_or(Value::Null), + }); + for key in [ + "method", + "methodDynamic", + "authority", + "channelPrefix", + "queryTailStripped", + "maskedSegments", + ] { + if let Some(v) = f.get(key) { + out[key] = v.clone(); + } + } + out +} + +fn fixture_doc() -> Value { + let dir = repo().join("tests/fixture-client"); + let wrappers = std::fs::read_to_string(dir.join("http-wrappers.json")).unwrap(); + doc_of(&dir.join("app"), Some(&wrappers)) +} + +/// 지금 출력의 사실이 오라클이 실제 요청으로 확인한 사실과 정확히 같아야 한다. +/// 다시 기록하려면 experiments/client-oracle/run.sh. +#[test] +fn fixture_matches_client_oracle() { + let rec = load(&repo().join("experiments/client-oracle/recorded/client.json")); + let totals = &rec["totals"]; + assert_eq!(totals["mismatch"], 0, "the oracle recorded mismatches"); + assert!(rec["unclaimedFacts"].as_array().unwrap().is_empty()); + assert!( + totals["match"].as_u64().unwrap() >= 30, + "the oracle matched too little" + ); + let recorded: BTreeSet = rec["scenarios"] + .as_array() + .unwrap() + .iter() + .flat_map(|s| s["facts"].as_array().unwrap().iter()) + .map(|f| f.to_string()) + .collect(); + let doc = fixture_doc(); + let now: BTreeSet = doc["facts"] + .as_array() + .unwrap() + .iter() + .map(|f| summary(f).to_string()) + .collect(); + assert_eq!(now, recorded, "re-run experiments/client-oracle/run.sh"); + // 요청이 없던 시나리오(보내기 전 실패)는 사실도 없다. + for s in rec["scenarios"].as_array().unwrap() { + if s["result"] == "no-request" { + assert!( + s["facts"].as_array().unwrap().is_empty(), + "{}", + s["scenario"] + ); + } + } +} + +#[test] +fn fixture_document_shape_and_limitations() { + let doc = fixture_doc(); + assert_eq!(doc["platform"], "rust"); + assert_eq!(doc["target"], "http"); + assert_eq!(doc["roles"], json!(["client"])); + assert_eq!(doc["sourceSets"]["tests"], "excluded"); + assert!(doc.get("dispatch").is_none()); + let lims: Vec<&str> = doc["limitations"] + .as_array() + .unwrap() + .iter() + .map(|l| l.as_str().unwrap()) + .collect(); + assert!( + lims.iter() + .any(|l| l.starts_with("ambiguous-base-join: 1 ")), + "{lims:?}" + ); + assert!( + lims.iter() + .any(|l| l.starts_with("http-wrapper-undeclared: 2 ")), + "{lims:?}" + ); + assert!(lims.iter().any(|l| l.contains("relative URL")), "{lims:?}"); + // 다른 언어 선언(swift)은 적용하지 않고 공백으로도 세지 않는다. + assert!(!lims.iter().any(|l| l.contains("Network")), "{lims:?}"); + // dynamic 사실은 원문 식을 싣지 않는다. + for f in doc["facts"].as_array().unwrap() { + if f["dynamic"] == true { + assert!(f["channel"].is_null()); + } + } + // 모든 usr가 reach 정점이다 — 같은 fixture의 그래프에서 확인한다. + let graph = rustograph::source::load( + &repo().join("tests/fixture-client/app"), + &rustograph::source::Options { + symbol_level: true, + ..Default::default() + }, + ) + .unwrap(); + let ids = graph.vertex_ids(); + for f in doc["facts"].as_array().unwrap() { + let usr = f["symbol"]["usr"].as_str().unwrap(); + assert!(ids.contains(usr), "{usr} is not a graph vertex"); + } +} + +// ── 추출 규칙(임시 크레이트) ──────────────────────────────── + +/// 임시 워크스페이스 — 스텁 의존성의 이름·버전과 소스 파일들. +fn temp_crate(tag: &str, deps: &[(&str, &str)], files: &[(&str, &str)]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("rg-client-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("ws/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("ws/Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("ws/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("ws/app/src").join(path), text).unwrap(); + } + dir.join("ws") +} + +/// (usr 끝 이름, method 또는 *, channel 또는 DYN:prefix, anchor). +fn rows(doc: &Value) -> Vec<(String, String, String, String)> { + doc["facts"] + .as_array() + .unwrap() + .iter() + .map(|f| { + let usr = f["symbol"]["usr"].as_str().unwrap_or("-"); + let short = usr.rsplit("::").next().unwrap_or(usr).to_string(); + let channel = match f["channel"].as_str() { + Some(c) => c.to_string(), + None => format!("DYN:{}", f["channelPrefix"].as_str().unwrap_or("")), + }; + ( + short, + f["method"].as_str().unwrap_or("*").to_string(), + channel, + f["pathAnchor"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +fn has( + rows: &[(String, String, String, String)], + usr: &str, + method: &str, + channel: &str, + anchor: &str, +) -> bool { + rows.iter() + .any(|r| r.0 == usr && r.1 == method && r.2 == channel && r.3 == anchor) +} + +fn limitations(doc: &Value) -> Vec { + doc["limitations"] + .as_array() + .unwrap() + .iter() + .map(|l| l.as_str().unwrap().to_string()) + .collect() +} + +#[test] +fn values_flow_through_locals_consts_and_types() { + let src = r#" +use reqwest::Client; +use std::sync::{Arc, OnceLock}; + +const HOST: &str = "http://h.test"; +const API: &str = concat!("http://h.test", "/api/", 2); +static SHARED: OnceLock = OnceLock::new(); + +pub struct Svc { http: Arc, root: String } + +impl Svc { + const BASE: &'static str = "http://h.test/svc"; + + pub fn make() -> Svc { Svc { http: Arc::new(Client::new()), root: format!("{}/root", HOST) } } + + fn client(&self) -> &Client { &self.http } + + pub fn assoc_const(&self) { self.client().get(format!("{}/a", Self::BASE)); } + pub fn field_root(&self) { self.http.get(self.root.clone() + "/x"); } + pub fn named_args(&self, id: u32) { self.http.get(format!("{h}/n/{id}", h = HOST)); } + pub fn debug_spec(&self, id: u32) { self.http.get(format!("{HOST}/d/{:?}", id)); } + pub fn width_arg(&self, id: u32) { self.http.get(format!("{HOST}/w/{:>1$}", id, 5)); } + pub fn concat_int(&self) { self.http.get(API); } +} + +fn shared() -> &'static Client { SHARED.get_or_init(Client::new) } + +pub fn once_lock() { shared().get(format!("{HOST}/once")); } +pub fn mutated() { let mut u = String::from(HOST); u.push_str("/m"); Client::new().get(u); } +pub fn shadowed() { let u = "http://h.test/one"; let u = format!("{u}/two"); Client::new().get(u); } +pub fn closure_param() { let c = Client::new(); let f = |u: &str| c.get(u); f("http://h.test/c"); } +pub fn loops(items: Vec<&str>) { let c = Client::new(); for it in items { c.delete(format!("{HOST}/l/{it}")); } } +pub fn matched(x: Option<&str>) { + let c = Client::new(); + match x { Some(v) => { c.put(format!("{HOST}/p/{v}")); } None => {} } + if let Some(v) = x { c.patch(format!("{HOST}/q/{v}")); } + while let Some(v) = x { c.head(format!("{HOST}/r/{v}")); break; } +} +pub fn query_match(page: u8) { + let q = match page { 0 => String::new(), n => format!("?p={n}") }; + Client::new().get(format!("{HOST}/qm{q}")); +} +pub fn no_else(page: u8) { + let q = if page > 0 { format!("?p={page}") }; + Client::new().get(format!("{HOST}/ne{q}")); +} +pub fn nested() { + fn inner() { reqwest::Client::new().get("http://h.test/inner"); } + inner(); +} +pub fn in_macro() { let c = Client::new(); let _ = vec![c.get("http://h.test/mac")]; } +pub fn request_new() { let _ = reqwest::Request::new(reqwest::Method::DELETE, "http://h.test/rn".parse().unwrap()); } +pub fn http_method() { Client::new().request(http::Method::TRACE, format!("{HOST}/t")); } + +pub trait Api { fn ping(&self, c: &Client) { c.get("http://h.test/ping"); } } + +#[cfg(test)] +mod tests { fn t() { reqwest::Client::new().get("http://h.test/test-only"); } } + +#[test] +fn test_fn() { reqwest::Client::new().get("http://h.test/test-fn"); } +"#; + let dir = temp_crate( + "values", + &[("reqwest", "0.13.5"), ("http", "1.3.1")], + &[("lib.rs", src)], + ); + let doc = doc_of(&dir, None); + let r = rows(&doc); + let expect = [ + ("assoc_const", "GET", "/svc/a", "root"), + ("field_root", "GET", "/root/x", "root"), + ("named_args", "GET", "/n/{}", "root"), + ("debug_spec", "GET", "/d/{}", "root"), + ("concat_int", "GET", "/api/2", "root"), + ("once_lock", "GET", "/once", "root"), + ("shadowed", "GET", "/one/two", "root"), + ("closure_param", "GET", "DYN:", "base"), + ("loops", "DELETE", "/l/{}", "root"), + ("matched", "PUT", "/p/{}", "root"), + ("matched", "PATCH", "/q/{}", "root"), + ("matched", "HEAD", "/r/{}", "root"), + ("query_match", "GET", "/qm", "root"), + ("no_else", "GET", "DYN:/ne", "root"), + ("mutated", "GET", "DYN:", "base"), + // 중첩 함수는 정점이 아니다 — 감싸는 함수가 usr다. + ("nested", "GET", "/inner", "root"), + ("in_macro", "GET", "/mac", "root"), + ("http_method", "TRACE", "/t", "root"), + ("ping", "GET", "/ping", "root"), + ]; + for (u, m, c, a) in expect { + assert!(has(&r, u, m, c, a), "missing {u} {m} {c} {a} in {r:#?}"); + } + // `{:>1$}`는 인자 순서를 바꾸는 서식이라 URL 전체를 모른다. + assert!(has(&r, "width_arg", "GET", "DYN:", "base"), "{r:#?}"); + // Request::new의 URL은 `.parse()` 결과(Url)라 모르는 값이다 — 동사는 확정한다. + assert!(has(&r, "request_new", "DELETE", "DYN:", "base"), "{r:#?}"); + assert!( + !r.iter().any(|x| x.2.contains("test")), + "test sources leaked: {r:#?}" + ); +} + +#[test] +fn fields_resolve_only_from_constant_constructors() { + let src = r#" +use reqwest::blocking::Client; + +#[derive(Default)] +pub struct Defaulted { base: String, http: Client } +impl Defaulted { pub fn go(&self) { self.http.get(format!("{}/d", self.base)); } } + +pub struct Two { base: String, http: Client } +impl Two { + pub fn a() -> Two { Two { base: "http://one.test".into(), http: Client::new() } } + pub fn b() -> Two { Two { base: "http://two.test".into(), http: Client::new() } } + pub fn go(&self) { self.http.get(format!("{}/t", self.base)); } +} + +pub struct Poisoned { base: String, http: Client } +impl Poisoned { + pub fn new() -> Poisoned { Poisoned { base: "http://p.test".into(), http: Client::new() } } + pub fn rebase(&mut self) { self.base = String::new(); } + pub fn go(&self) { self.http.get(format!("{}/p", self.base)); } +} + +pub struct Rest { base: String, http: Client } +impl Rest { + pub fn new(other: Rest) -> Rest { Rest { http: Client::new(), ..other } } + pub fn go(&self) { self.http.get(format!("{}/r", self.base)); } +} + +pub struct Other { base: String } +pub fn via_instance(o: &Other, c: &Client) { + let o2 = Other { base: "http://o.test/x".to_string() }; + c.get(format!("{}/i", o.base)); + c.get(format!("{}/j", o2.base)); +} +"#; + let dir = temp_crate("fields", &[("reqwest", "0.12.9")], &[("lib.rs", src)]); + let doc = doc_of(&dir, None); + let r = rows(&doc); + // 네 구조체 모두 필드 값이 확정되지 않아 base 앵커다. + let n = r + .iter() + .filter(|x| x.0 == "go" && x.3 == "base" && !x.2.starts_with("DYN")) + .count(); + assert_eq!(n, 4, "{r:#?}"); + assert!(has(&r, "via_instance", "GET", "/x/i", "root"), "{r:#?}"); + assert!(has(&r, "via_instance", "GET", "/x/j", "root"), "{r:#?}"); + let base_refs: BTreeSet<&str> = doc["facts"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["baseRef"].as_str()) + .collect(); + assert!(base_refs.contains("app::Two::base"), "{base_refs:?}"); +} + +#[test] +fn ureq_versions_and_agents() { + let src = r#" +pub fn free() { ureq::get("http://u.test/a/../b").call(); } +pub fn agent2() { let a = ureq::AgentBuilder::new().build(); a.put("http://u.test/p").call(); } +pub fn agent3() { let a = ureq::Agent::config_builder().build().new_agent(); a.options("http://u.test/o").call(); } +pub fn request2(m: &str) { + ureq::request("PATCH", "http://u.test/rq").call(); + ureq::request("get", "http://u.test/lower").call(); + ureq::request(m, "http://u.test/param").call(); +} +pub fn unproven(x: &Thing) { x.get("http://u.test/nope").call(); } +pub struct Thing; +"#; + let v2 = doc_of( + &temp_crate("ureq2", &[("ureq", "2.12.1")], &[("lib.rs", src)]), + None, + ); + let r2 = rows(&v2); + // ureq 2는 url::Url이라 점 세그먼트를 지운다. + assert!(has(&r2, "free", "GET", "/b", "root"), "{r2:#?}"); + assert!(has(&r2, "agent2", "PUT", "/p", "root"), "{r2:#?}"); + assert!(has(&r2, "request2", "PATCH", "/rq", "root"), "{r2:#?}"); + assert!(has(&r2, "request2", "*", "/lower", "root"), "{r2:#?}"); + assert!(has(&r2, "request2", "*", "/param", "root"), "{r2:#?}"); + let l2 = limitations(&v2); + assert!(l2.iter().any(|l| l.contains("ureq 2.12.1")), "{l2:?}"); + assert!(l2.iter().any(|l| l.contains("not a proven")), "{l2:?}"); + assert!( + l2.iter() + .any(|l| l.starts_with("http-wrapper-undeclared: 1 ")), + "{l2:?}" + ); + let v3 = doc_of( + &temp_crate("ureq3", &[("ureq", "3.4.2")], &[("lib.rs", src)]), + None, + ); + let r3 = rows(&v3); + assert!(has(&r3, "free", "GET", "/a/../b", "root"), "{r3:#?}"); + assert!(has(&r3, "agent3", "OPTIONS", "/o", "root"), "{r3:#?}"); + assert!(!limitations(&v3).iter().any(|l| l.contains("ureq 3.4.2"))); +} + +#[test] +fn unmodelled_clients_and_versions_are_counted() { + let src = r#" +pub async fn a() { let _ = surf::get("http://s.test/x").await; } +pub fn b() { let _c = hyper_util::client::legacy::Client::builder(); } +pub fn serve() { let _s = hyper::server::conn::http1::Builder::new(); } +pub fn old() { reqwest::get("http://r.test/old"); } +"#; + let dir = temp_crate( + "unmodelled", + &[ + ("surf", "2.3.2"), + ("hyper-util", "0.1.10"), + ("hyper", "1.6.0"), + ("reqwest", "0.11.27"), + ], + &[("lib.rs", src)], + ); + let doc = doc_of(&dir, None); + let l = limitations(&doc); + assert!(l.iter().any(|x| x.contains("1 use(s) of surf")), "{l:?}"); + assert!( + l.iter().any(|x| x.contains("1 use(s) of hyper_util")), + "{l:?}" + ); + // 서버 쪽 hyper 경로는 세지 않는다. + assert!( + !l.iter() + .any(|x| x.contains("of hyper,") || x.contains("of hyper ")), + "{l:?}" + ); + assert!(l.iter().any(|x| x.contains("reqwest 0.11.27")), "{l:?}"); + assert!(has(&rows(&doc), "old", "GET", "/old", "root")); +} + +#[test] +fn wrapper_declarations_resolve_or_report() { + let src = r#" +pub enum M { Get, Put } +pub struct Api; +impl Api { + pub fn call(&self, m: M, path: &str) {} + pub fn raw(m: M, path: &str) {} +} +pub trait Send2 { fn send2(&self, path: &str); } +impl Send2 for Api { fn send2(&self, path: &str) {} } +pub struct Ep(pub M, pub &'static str); +pub fn go(api: &Api, id: u32, dynamic_verb: M) { + api.call(M::Put, "/a"); + Api::call(api, M::Get, "/ufcs"); + Api::raw(M::Get, "https://Other.test/abs?x=1"); + api.send2(&format!("/t/{id}")); + let _ = Ep(M::Put, "/tuple"); + api.call(dynamic_verb, "relative"); +} +"#; + let dir = temp_crate("wrappers", &[], &[("lib.rs", src)]); + let wrappers = r#"{"format":"http-wrappers","version":1,"wrappers":[ + {"language":"rust","kind":"function","owner":"app::Api","name":"call","methodArg":{"index":0},"methodEnum":{"Get":"GET","Put":"PUT"},"pathArg":{"index":1},"pathAnchor":"base","service":"api"}, + {"language":"rust","kind":"function","owner":"app::Api","name":"raw","methodArg":{"index":0},"methodEnum":{"Get":"GET"},"pathArg":{"index":1},"pathAnchor":"root"}, + {"language":"rust","kind":"function","owner":"app::Api::","name":"send2","pathArg":{"index":0},"defaultMethod":"POST","pathAnchor":"root"}, + {"language":"rust","kind":"constructor","owner":"app::Ep","name":"Ep","methodArg":{"index":0},"methodEnum":{"Put":"PUT"},"pathArg":{"index":1},"pathAnchor":"root"}, + {"language":"rust","kind":"function","owner":"app::Missing","name":"nope","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}, + {"language":"rust","kind":"constructor","owner":"app::Api","name":"new","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}, + {"language":"kotlin","kind":"function","owner":"x","name":"y","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"} + ]}"#; + let doc = doc_of(&dir, Some(wrappers)); + let r = rows(&doc); + assert!(has(&r, "go", "PUT", "/a", "base"), "{r:#?}"); + assert!(has(&r, "go", "GET", "/ufcs", "base"), "{r:#?}"); + assert!(has(&r, "go", "GET", "/abs", "root"), "{r:#?}"); + assert!(has(&r, "go", "POST", "/t/{}", "root"), "{r:#?}"); + assert!(has(&r, "go", "PUT", "/tuple", "root"), "{r:#?}"); + assert!(has(&r, "go", "*", "DYN:", "base"), "{r:#?}"); + let abs = doc["facts"] + .as_array() + .unwrap() + .iter() + .find(|f| f["channel"] == "/abs") + .unwrap(); + assert_eq!(abs["authority"], "other.test"); + assert_eq!(abs["queryTailStripped"], true); + let svc = doc["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["service"] == "api") + .count(); + assert_eq!(svc, 3); + let l = limitations(&doc); + assert!( + l.iter() + .any(|x| x.contains("wrappers[4] (app::Missing::nope) does not name")), + "{l:?}" + ); + assert!( + l.iter() + .any(|x| x.contains("wrappers[5] (app::Api::new) does not name")), + "{l:?}" + ); + assert!( + l.iter().any(|x| x.starts_with("ambiguous-base-join: 1 ")), + "{l:?}" + ); + assert!(!l.iter().any(|x| x.contains("wrappers[6]")), "{l:?}"); + // 호출 0건 선언. + let zero = r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"app","name":"go","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}]}"#; + let l0 = limitations(&doc_of(&dir, Some(zero))); + assert!( + l0.iter() + .any(|x| x.contains("wrappers[0] (app::go) has no calls")), + "{l0:?}" + ); +} + +#[test] +fn service_conflict_is_a_usage_error() { + let dir = temp_crate("service", &[], &[("lib.rs", "pub fn f(p: &str) {}\n")]); + let wrappers = wrappers::parse(r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"app","name":"f","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root","service":"a"}]}"#).unwrap(); + let opts = ClientOptions { + wrappers, + service: Some("b".into()), + }; + let err = client_routes(&dir, "test", &opts).err().expect("conflict"); + assert!(err.contains("service"), "{err}"); + let ok = ClientOptions { + wrappers: Vec::new(), + service: Some("b".into()), + }; + let doc = serde_json::to_value(client_routes(&dir, "test", &ok).unwrap()).unwrap(); + assert_eq!(doc["service"], "b"); + assert!(doc["facts"].as_array().unwrap().is_empty()); + assert_eq!(doc["target"], "http"); +} + +/// 상수가 상수를 가리키는 긴 사슬도 깊이 상한 안에서 끝난다 — 스택을 넘기지 않고 +/// 모르는 값(baseRef)으로 낮추며, 짧은 사슬은 그대로 푼다. +#[test] +fn deep_const_chains_degrade_instead_of_overflowing() { + let mut src = String::from("const C0: &str = \"http://h.test/x\";\n"); + for i in 1..3000 { + src.push_str(&format!("const C{i}: &str = C{};\n", i - 1)); + } + src.push_str( + "pub fn deep() { reqwest::get(C2999); }\npub fn shallow() { reqwest::get(C3); }\n", + ); + let dir = temp_crate("deepconst", &[("reqwest", "0.13.5")], &[("lib.rs", &src)]); + let r = rows(&doc_of(&dir, None)); + assert!(has(&r, "deep", "GET", "DYN:", "base"), "{r:#?}"); + assert!(has(&r, "shallow", "GET", "/x", "root"), "{r:#?}"); +} diff --git a/tests/fixture-client/app/Cargo.lock b/tests/fixture-client/app/Cargo.lock new file mode 100644 index 0000000..87b4eed --- /dev/null +++ b/tests/fixture-client/app/Cargo.lock @@ -0,0 +1,24 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "client_app" +version = "0.1.0" +dependencies = [ + "reqwest", + "ureq", + "url", +] + +[[package]] +name = "reqwest" +version = "0.13.5" + +[[package]] +name = "ureq" +version = "3.4.2" + +[[package]] +name = "url" +version = "2.5.8" diff --git a/tests/fixture-client/app/Cargo.toml b/tests/fixture-client/app/Cargo.toml new file mode 100644 index 0000000..f3e472f --- /dev/null +++ b/tests/fixture-client/app/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["client_app"] +resolver = "2" diff --git a/tests/fixture-client/app/client_app/Cargo.toml b/tests/fixture-client/app/client_app/Cargo.toml new file mode 100644 index 0000000..f77f5bf --- /dev/null +++ b/tests/fixture-client/app/client_app/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "client_app" +version = "0.1.0" +edition = "2021" +publish = false + +# 의존성은 버전 판정용 스텁이다(../stubs). 같은 소스를 진짜 reqwest 0.13.5·ureq 3.4.2· +# url 2.5.8로 컴파일해 로컬 서버로 요청을 보내는 오라클은 experiments/client-oracle에 있다. +[dependencies] +reqwest = { path = "../../stubs/reqwest" } +ureq = { path = "../../stubs/ureq" } +url = { path = "../../stubs/url" } diff --git a/tests/fixture-client/app/client_app/src/api.rs b/tests/fixture-client/app/client_app/src/api.rs new file mode 100644 index 0000000..05df2b5 --- /dev/null +++ b/tests/fixture-client/app/client_app/src/api.rs @@ -0,0 +1,134 @@ +//! 구조체 필드에 base URL을 두는 API 클라이언트 — e2e에서 axum08 fixture 서버의 +//! `/api/items` 라우트를 부른다. + +use crate::Res; +use reqwest::blocking::Client; +use url::Url; + +/// 생성자가 base를 상수로 채운다 — 필드 값이 리터럴로 풀린다. +pub struct ApiClient { + base_url: String, + http: Client, +} + +/// API base(경로 포함). +const API_BASE: &str = "http://api.example.com/api"; + +impl ApiClient { + pub fn new() -> Self { + Self { + base_url: API_BASE.to_string(), + http: Client::new(), + } + } + + pub fn list_items(&self) -> Res { + self.http.get(format!("{}/items", self.base_url)).send()?; + Ok(()) + } + + pub fn get_item(&self, id: u64) -> Res { + self.http + .get(format!("{}/items/{id}", self.base_url)) + .send()?; + Ok(()) + } + + pub fn create_item(&self) -> Res { + self.http.post(self.base_url.clone() + "/items").send()?; + Ok(()) + } + + pub fn search(&self, q: &str) -> Res { + self.http + .get(format!("{}/search?q={q}", self.base_url)) + .send()?; + Ok(()) + } + + /// 선언된 래퍼(`http-wrappers.json`) — 동사는 enum, 경로는 base 뒤다. 본문의 동적 + /// 호출은 래퍼 호출 사실이 대신한다. + pub fn send(&self, verb: Verb, path: &str) -> Res { + let method = match verb { + Verb::Get => reqwest::Method::GET, + Verb::Post => reqwest::Method::POST, + Verb::Delete => reqwest::Method::DELETE, + }; + self.http + .request(method, format!("{}{}", self.base_url, path)) + .send()?; + Ok(()) + } + + /// 구조체 리터럴 생성 래퍼 `Endpoint`를 실행한다. + pub fn execute(&self, ep: Endpoint) -> Res { + self.send(ep.verb, &ep.path) + } +} + +impl Default for ApiClient { + fn default() -> Self { + Self::new() + } +} + +/// 래퍼 동사 enum. +pub enum Verb { + Get, + Post, + Delete, +} + +/// 생성자 래퍼(`kind: constructor`, 이름 = 타입 이름) — 필드 레이블로 묶는다. +pub struct Endpoint { + pub verb: Verb, + pub path: String, +} + +/// 생성자가 base를 매개변수로 받는다 — 필드가 풀리지 않아 base 앵커와 baseRef다. +pub struct RemoteClient { + base: String, + http: Client, +} + +impl RemoteClient { + pub fn new(base: &str) -> Self { + RemoteClient { + base: base.to_string(), + http: Client::new(), + } + } + + pub fn user(&self, id: u32) -> Res { + self.http + .get(format!("{}/users/{}", self.base, id)) + .send()?; + Ok(()) + } +} + +/// `url::Url` 필드 — `Url::join`(RFC 3986 병합)으로 잇는다. +pub struct Catalog { + base: Url, + http: Client, +} + +impl Catalog { + pub fn new() -> Self { + Catalog { + base: Url::parse("http://api.example.com/cat/").unwrap(), + http: Client::new(), + } + } + + pub fn products(&self) -> Res { + self.http.get(self.base.join("products")?).send()?; + Ok(()) + } +} + +impl Default for Catalog { + fn default() -> Self { + Self::new() + } +} diff --git a/tests/fixture-client/app/client_app/src/lib.rs b/tests/fixture-client/app/client_app/src/lib.rs new file mode 100644 index 0000000..404c603 --- /dev/null +++ b/tests/fixture-client/app/client_app/src/lib.rs @@ -0,0 +1,14 @@ +//! 합성 HTTP 클라이언트 fixture — rustograph `routes --role client`가 읽고, +//! experiments/client-oracle이 같은 소스를 진짜 reqwest·ureq·url로 컴파일해 로컬 +//! 서버로 요청을 보낸다. 시나리오 함수 하나가 요청 하나를 보낸다. + +pub mod api; +pub mod scenarios; +pub mod ureq_calls; +pub mod wrappers; + +/// 시나리오 결과 — 오라클은 오류도 기록한다(상대 URL은 보내기 전에 실패한다). +pub type Res = Result<(), Box>; + +/// 모든 리터럴 URL의 host. +pub const HOST: &str = "http://api.example.com"; diff --git a/tests/fixture-client/app/client_app/src/scenarios.rs b/tests/fixture-client/app/client_app/src/scenarios.rs new file mode 100644 index 0000000..7f3435b --- /dev/null +++ b/tests/fixture-client/app/client_app/src/scenarios.rs @@ -0,0 +1,190 @@ +//! reqwest 시나리오 — 리터럴·format!·concat!·+·Url::parse/join·지역 변수·static. + +use crate::{Res, HOST}; +use reqwest::blocking::Client; +use std::sync::LazyLock; +use url::Url; + +/// 경로가 있는 base. +const V1: &str = "http://api.example.com/v1"; + +/// 끝 슬래시가 있는 base — 문자열 연결은 `//`를 남긴다. +const V3_SLASH: &str = "http://api.example.com/v3/"; + +/// 전역 클라이언트. +static CLIENT: LazyLock = LazyLock::new(Client::new); + +pub fn literal_query() -> Res { + reqwest::blocking::get("http://api.example.com/v1/items?page=2")?; + Ok(()) +} + +pub async fn async_user(id: u64) -> Result<(), reqwest::Error> { + let client = reqwest::Client::new(); + client.get(format!("{V1}/users/{id}")).send().await?; + Ok(()) +} + +pub fn concat_post() -> Res { + Client::new() + .post(concat!("http://api.example.com", "/v1/orders")) + .send()?; + Ok(()) +} + +pub fn put_positional(id: u32) -> Res { + let client = Client::new(); + client.put(format!("{}/v1/items/{}", HOST, id)).send()?; + Ok(()) +} + +pub fn delete_plus(id: &str) -> Res { + CLIENT + .delete(HOST.to_string() + "/v1/items/" + id) + .send()?; + Ok(()) +} + +pub fn patch_local() -> Res { + let url = format!("{HOST}/v1/profile"); + CLIENT.patch(&url).send()?; + Ok(()) +} + +pub fn head_health() -> Res { + CLIENT.head(format!("{V1}/health")).send()?; + Ok(()) +} + +pub fn request_options() -> Res { + CLIENT + .request(reqwest::Method::OPTIONS, format!("{V1}/items")) + .send()?; + Ok(()) +} + +pub fn request_dynamic(method: reqwest::Method) -> Res { + CLIENT.request(method, format!("{V1}/verbs")).send()?; + Ok(()) +} + +pub fn join_relative(id: u32) -> Res { + let base = Url::parse("http://api.example.com/v2/")?; + CLIENT.get(base.join(&format!("users/{id}"))?).send()?; + Ok(()) +} + +pub fn join_replaces_last() -> Res { + let url = Url::parse("http://api.example.com/v2/catalog")?.join("tags")?; + CLIENT.get(url).send()?; + Ok(()) +} + +pub fn join_absolute_path() -> Res { + let url = Url::parse(V1)?.join("/root/ping")?; + CLIENT.get(url.as_str()).send()?; + Ok(()) +} + +pub fn dot_segments() -> Res { + CLIENT.get("http://api.example.com/v1/a/../b").send()?; + Ok(()) +} + +pub fn double_slash() -> Res { + CLIENT.get(format!("{V3_SLASH}/items")).send()?; + Ok(()) +} + +pub fn query_tail(page: Option) -> Res { + let q = if let Some(p) = page { + format!("?page={p}") + } else { + String::new() + }; + CLIENT.get(format!("{V1}/search{q}")).send()?; + Ok(()) +} + +pub fn partial_segment(name: &str) -> Res { + CLIENT.get(format!("{V1}/files/{name}.json")).send()?; + Ok(()) +} + +pub fn unknown_base_rooted(base: &str) -> Res { + CLIENT.get(format!("{base}/v1/status")).send()?; + Ok(()) +} + +pub fn unknown_base_glued(base: &str) -> Res { + CLIENT.get(format!("{base}status")).send()?; + Ok(()) +} + +pub fn relative_url() -> Res { + CLIENT.get("/v1/relative").send()?; + Ok(()) +} + +pub fn masked_token() -> Res { + CLIENT + .get(format!("{V1}/tokens/a1b2c3d4e5f6a7b8c9d0")) + .send()?; + Ok(()) +} + +pub fn non_ascii() -> Res { + CLIENT.get("http://api.example.com/v1/café").send()?; + Ok(()) +} + +pub fn userinfo_fragment() -> Res { + CLIENT + .get("http://user:pw@API.example.com/v1/secure#top") + .send()?; + Ok(()) +} + +pub fn with_port() -> Res { + CLIENT.get("http://api.example.com:8080/v1/port").send()?; + Ok(()) +} + +pub fn backslashes() -> Res { + CLIENT.get("http://api.example.com\\v1\\bs").send()?; + Ok(()) +} + +pub fn builder_client() -> Res { + let client = reqwest::blocking::Client::builder() + .user_agent("fixture") + .build()?; + client.get(format!("{V1}/built")).send()?; + Ok(()) +} + +pub fn struct_field_base() -> Res { + let api = crate::api::ApiClient::new(); + api.list_items()?; + Ok(()) +} + +pub fn struct_field_item() -> Res { + crate::api::ApiClient::new().get_item(42) +} + +pub fn struct_field_plus() -> Res { + crate::api::ApiClient::new().create_item() +} + +pub fn struct_field_query() -> Res { + crate::api::ApiClient::new().search("x") +} + +pub fn struct_param_base(base: &str) -> Res { + crate::api::RemoteClient::new(base).user(3) +} + +pub fn url_field() -> Res { + crate::api::Catalog::new().products() +} diff --git a/tests/fixture-client/app/client_app/src/ureq_calls.rs b/tests/fixture-client/app/client_app/src/ureq_calls.rs new file mode 100644 index 0000000..f743678 --- /dev/null +++ b/tests/fixture-client/app/client_app/src/ureq_calls.rs @@ -0,0 +1,30 @@ +//! ureq 3 시나리오 — 자유 함수와 Agent. ureq 3은 `http::Uri`로 해석해 점 세그먼트를 +//! 지우지 않는다. + +use crate::{Res, HOST}; + +pub fn ureq_get() -> Res { + ureq::get("http://api.example.com/v1/ureq/items").call()?; + Ok(()) +} + +pub fn ureq_delete(id: u32) -> Res { + ureq::delete(&format!("{HOST}/v1/ureq/items/{id}")).call()?; + Ok(()) +} + +pub fn ureq_dots() -> Res { + ureq::get("http://api.example.com/v1/x/../ureq-dots").call()?; + Ok(()) +} + +pub fn ureq_agent_head() -> Res { + let agent: ureq::Agent = ureq::Agent::new_with_defaults(); + agent.head(format!("{HOST}/v1/ureq/health")).call()?; + Ok(()) +} + +pub fn ureq_post() -> Res { + ureq::post("http://api.example.com/v1/ureq/orders").send_empty()?; + Ok(()) +} diff --git a/tests/fixture-client/app/client_app/src/wrappers.rs b/tests/fixture-client/app/client_app/src/wrappers.rs new file mode 100644 index 0000000..23f087e --- /dev/null +++ b/tests/fixture-client/app/client_app/src/wrappers.rs @@ -0,0 +1,41 @@ +//! 선언된 래퍼(`tests/fixture-client/http-wrappers.json`)와 선언되지 않은 싱크. + +use crate::api::{ApiClient, Endpoint, Verb}; +use crate::{Res, HOST}; +use reqwest::blocking::Client; + +/// 함수 래퍼 base. +const WRAP_BASE: &str = "http://api.example.com/wb"; + +/// 선언된 함수 래퍼 — 기본 동사 GET, 경로는 첫 인자. +pub fn fetch_json(path: &str) -> Res { + Client::new().get(format!("{WRAP_BASE}{path}")).send()?; + Ok(()) +} + +/// 선언되지 않은 싱크 — 매개변수를 URL로 흘려보낸다(`http-wrapper-undeclared:`). +pub fn raw_get(path: &str) -> Res { + Client::new().get(format!("{HOST}{path}")).send()?; + Ok(()) +} + +pub fn wrapper_function() -> Res { + fetch_json("/w/items") +} + +pub fn wrapper_method() -> Res { + let api = ApiClient::new(); + api.send(Verb::Post, "/w/orders") +} + +pub fn wrapper_constructor(id: u32) -> Res { + let api = ApiClient::new(); + api.execute(Endpoint { + verb: Verb::Delete, + path: format!("/e/items/{id}"), + }) +} + +pub fn undeclared_sink() -> Res { + raw_get("/v1/undeclared") +} diff --git a/tests/fixture-client/http-wrappers.json b/tests/fixture-client/http-wrappers.json new file mode 100644 index 0000000..8b536d1 --- /dev/null +++ b/tests/fixture-client/http-wrappers.json @@ -0,0 +1,44 @@ +{ + "format": "http-wrappers", + "version": 1, + "wrappers": [ + { + "language": "rust", + "kind": "function", + "owner": "client_app::wrappers", + "name": "fetch_json", + "pathArg": { "index": 0 }, + "defaultMethod": "GET", + "pathAnchor": "base" + }, + { + "language": "rust", + "kind": "function", + "owner": "client_app::api::ApiClient", + "name": "send", + "methodArg": { "index": 0 }, + "methodEnum": { "Get": "GET", "Post": "POST", "Delete": "DELETE" }, + "pathArg": { "index": 1 }, + "pathAnchor": "base" + }, + { + "language": "rust", + "kind": "constructor", + "owner": "client_app::api::Endpoint", + "name": "Endpoint", + "methodArg": { "label": "verb" }, + "methodEnum": { "Get": "GET", "Post": "POST", "Delete": "DELETE" }, + "pathArg": { "label": "path" }, + "pathAnchor": "base" + }, + { + "language": "swift", + "kind": "function", + "owner": "Network", + "name": "send", + "pathArg": { "label": "path" }, + "defaultMethod": "GET", + "pathAnchor": "root" + } + ] +} diff --git a/tests/fixture-client/stubs/reqwest/Cargo.toml b/tests/fixture-client/stubs/reqwest/Cargo.toml new file mode 100644 index 0000000..f85cfa6 --- /dev/null +++ b/tests/fixture-client/stubs/reqwest/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "reqwest" +version = "0.13.5" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# URL 해석 규칙을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 요청은 experiments/client-oracle이 crates.io의 진짜 크레이트로 보낸다. diff --git a/tests/fixture-client/stubs/reqwest/src/lib.rs b/tests/fixture-client/stubs/reqwest/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-client/stubs/reqwest/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/fixture-client/stubs/ureq/Cargo.toml b/tests/fixture-client/stubs/ureq/Cargo.toml new file mode 100644 index 0000000..26277ef --- /dev/null +++ b/tests/fixture-client/stubs/ureq/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ureq" +version = "3.4.2" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# URL 해석 규칙을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 요청은 experiments/client-oracle이 crates.io의 진짜 크레이트로 보낸다. diff --git a/tests/fixture-client/stubs/ureq/src/lib.rs b/tests/fixture-client/stubs/ureq/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-client/stubs/ureq/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/fixture-client/stubs/url/Cargo.toml b/tests/fixture-client/stubs/url/Cargo.toml new file mode 100644 index 0000000..09a659a --- /dev/null +++ b/tests/fixture-client/stubs/url/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "url" +version = "2.5.8" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# URL 해석 규칙을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 요청은 experiments/client-oracle이 crates.io의 진짜 크레이트로 보낸다. diff --git a/tests/fixture-client/stubs/url/src/lib.rs b/tests/fixture-client/stubs/url/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-client/stubs/url/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/routes.rs b/tests/routes.rs index 14979ab..73a8159 100644 --- a/tests/routes.rs +++ b/tests/routes.rs @@ -35,8 +35,11 @@ const SKIPPED: &[&str] = &[ "dispatch.match", "dispatch.shadow", "scope.applies", + // dynamic 선언의 `dynamicScope` — rustograph는 이 선택 필드를 내지 않는다. + "scope.dynamic-", "framework.openapi.", "framework.spring.", + // 클라이언트 조립 규칙 — tests/client_routes.rs가 실행한다. "compose.", "wrapper.", ];