From 69e3fb28364f07201f51e36b7122bb9c9fcf2b2f Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:13:44 +0900 Subject: [PATCH] =?UTF-8?q?HTTP=20query=20=EB=B0=B0=EC=97=B4=EC=9D=84=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=ED=82=A4=EB=A1=9C=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=ED=95=98=EA=B3=A0=20schema=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20profile=20v2=EB=A5=BC=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 3 + crates/xgen-cli/src/composition.rs | 2 +- crates/xgen-cli/src/graph_discovery.rs | 2 +- crates/xgen-cli/src/http_read.rs | 155 +++++++++++++++++++++- crates/xgen-cli/src/tool_graph_worker.py | 16 ++- docs/adr/0082-array-query-parameters.md | 47 +++++++ docs/development/http-read-tools.md | 7 +- docs/development/tool-integration-todo.md | 6 +- scripts/tests/test_http_read_tools.py | 127 ++++++++++++++++-- 9 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 docs/adr/0082-array-query-parameters.md diff --git a/MEMORY.md b/MEMORY.md index 05ef21f..f2655d8 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -162,3 +162,6 @@ - HTTP 조회 확장 요청(2026-10-31, 사용자 승인): 범용 기능으로 선택 unsupported 파라미터 단위 제외 → query 배열(form/explode, Swagger multi) → operation별 호스트 지정 POST 조회 → Run별 bearer env 주입 순서다. 1번은 단독 PR로 먼저 제공한다. 승인 있는 쓰기는 대회 후 별도 설계다. 상세 정본은 [통합 TODO](docs/development/tool-integration-todo.md#http-조회-확장-요청-2026-10-31). 외부 커머스 API는 통합 사례로만 쓰고 설계 기준에 넣지 않는다. 생성기·endpoint·인증·비교기는 외부 통합 담당자의 책임이며 실제 실행 가능 과업 수는 미검증이다. - 선택 HTTP 파라미터(ADR-0081, 구현·로컬 검증): override 병합 뒤 지원 밖 선택 항목만 제외하고 name/location/reason을 contract digest와 모델 describe에 포함한다. required/path·보안/응답 오류는 계속 operation을 거절한다. 기존 host의 closed parameter 검사로 제외 입력은 요청 전에 거절된다. assets/calendar 설계와 shipment/sensor 실제 loopback import→describe→GET·악의적 제외 입력 0회 요청을 확인했다. 기존 HTTP 실패 테스트의 즉시 종료 기대를 ADR-0052의 실패 관찰 후 보고 계약에 맞췄으며 엔진 실패 의미는 바꾸지 않았다. 자체 과제 평가는 [draft #138](https://github.com/PlateerLab/xgen-cli/pull/138)에 보존했고 모델 인증 대기는 계속된다. - 선택 파라미터 검증 결과: Rust778개(4 ignored), system Python venv+graph-tool-call0.47.0+컴파일 CLI의 전체139개 검사(7 skipped), HTTP 관련18개 전부 통과했다. Clippy·fmt·protocol·문서·workflow 검사도 통과했다. UV standalone Python의 pidfd_open 부재는 기존 평가 process-cleanup 도구의 환경 제약으로 분리했고 system Python에서 전체 검증했다. + +- HTTP 확장1은 [PR #139](https://github.com/PlateerLab/xgen-cli/pull/139)로 main `0c29e15`에 머지했다. [Fast/Linux PR 검사](https://github.com/PlateerLab/xgen-cli/actions/runs/41084874587) 통과. +- HTTP 확장2(ADR-0082, 구현·로컬 검증 완료): query 배열은 OpenAPI3 form/explode=true와 Swagger2 multi만 지원한다. scalar items와 배열 제약을 전체 schema로 검증하고 반복 query pair를 host에서 인코딩한다. 다른 직렬화·object items는 선택 제외/required 거절을 유지한다. 실행 profile은 한 상수 `xgen.http.read/v2`로 binding/profile에 고정한다. 이전 바이너리의 미완료 HTTP Run은 새 바이너리 resume에서 execution_profile drift로 요청0회 거절, 완료 Run은 네트워크 없이 재생됨을 실제 loopback fixture로 확인했다. 배열 wire·잘못된 값 0회 요청도 shipment/sensor에서 확인했으며 Rust781개(4 ignored)·pinned backend/컴파일 CLI 전체144개 검사(7 skipped)·Clippy·fmt·문서·workflow 검사를 통과했다. diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 12e4eab..cd40342 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -3142,7 +3142,7 @@ fn execution_profile_with_discovery( "material_provider":graph_discovery::PROVIDER, "material_recipe_domain":"xgen.cli.tool-discovery-recipe/v1", "host_policy_profile":"xgen.cli.host-tool-discovery/v1", - "http_read_profile": if connections.is_empty() { serde_json::Value::Null } else { serde_json::json!({"version":1,"get_only":true,"max_body_bytes":32768,"timeout_seconds":30,"redirects":false,"proxy":false}) }, + "http_read_profile": if connections.is_empty() { serde_json::Value::Null } else { serde_json::json!({"profile":crate::http_read::PROFILE,"get_only":true,"max_body_bytes":32768,"timeout_seconds":30,"redirects":false,"proxy":false}) }, }); if connections.is_empty() { profile diff --git a/crates/xgen-cli/src/graph_discovery.rs b/crates/xgen-cli/src/graph_discovery.rs index 40b7bd4..dbafa37 100644 --- a/crates/xgen-cli/src/graph_discovery.rs +++ b/crates/xgen-cli/src/graph_discovery.rs @@ -298,7 +298,7 @@ pub(crate) struct DiscoveryAdapter { impl DiscoveryAdapter { pub(crate) fn binding(&self) -> InstanceBinding { let encoded = if self.operation == crate::http_read::READ { - serde_jcs::to_vec(&serde_json::json!({"snapshots":self.catalog.snapshots,"connections":self.catalog.connections,"profile":"xgen.http.read/v1"})).expect("finite host profile") + serde_jcs::to_vec(&serde_json::json!({"snapshots":self.catalog.snapshots,"connections":self.catalog.connections,"profile":crate::http_read::PROFILE})).expect("finite host profile") } else { serde_jcs::to_vec(&self.catalog.snapshots).expect("finite snapshot map") }; diff --git a/crates/xgen-cli/src/http_read.rs b/crates/xgen-cli/src/http_read.rs index 6fe5e4d..f648379 100644 --- a/crates/xgen-cli/src/http_read.rs +++ b/crates/xgen-cli/src/http_read.rs @@ -17,9 +17,11 @@ use xgen_runtime::{ use zeroize::Zeroizing; pub(crate) const READ: &str = "xgen.http/read"; +pub(crate) const PROFILE: &str = "xgen.http.read/v2"; pub(crate) const SCOPE: &str = "http.read"; pub(crate) type Connections = BTreeMap; const MAX_BODY: usize = 32 * 1024; +const MAX_TARGET_BYTES: usize = 8 * 1024; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -353,6 +355,30 @@ fn scalar(value: &Value) -> Result { } } +fn parameter_values(value: &Value, definition: &Value) -> Result, &'static str> { + let values: Vec<&Value> = + if definition["location"] == "query" && definition["schema"]["type"] == "array" { + let values = value.as_array().ok_or("http_parameter_type_invalid")?; + // Empty exploded lists have no wire representation (RFC 6570); required cannot be omitted. + if values.is_empty() && definition["required"] == true { + return Err("http_parameter_required"); + } + values.iter().collect() + } else { + vec![value] + }; + values + .into_iter() + .map(|value| { + let text = scalar(value)?; + if text.len() > 2048 || text.chars().any(char::is_control) { + return Err("http_parameter_invalid"); + } + Ok(text) + }) + .collect() +} + fn request_url( connection: &Connection, contract: &Value, @@ -401,10 +427,7 @@ fn request_url( if !validator.is_valid(value) { return Err("http_parameter_schema_invalid"); } - let text = scalar(value)?; - if text.len() > 2048 || text.chars().any(char::is_control) { - return Err("http_parameter_invalid"); - } + parameter_values(value, definition)?; } else if definition["required"] == true { return Err("http_parameter_required"); } @@ -442,9 +465,18 @@ fn request_url( } } for (name, value) in arguments["query"].as_object().ok_or("http_input_invalid")? { - url.query_pairs_mut().append_pair(name, &scalar(value)?); + let definition = definitions + .iter() + .find(|p| p["name"] == name.as_str() && p["location"] == "query") + .ok_or("http_parameter_unknown")?; + for text in parameter_values(value, definition)? { + url.query_pairs_mut().append_pair(name, &text); + if url.as_str().len() > MAX_TARGET_BYTES { + return Err("http_target_size_limit"); + } + } } - if url.as_str().len() > 8192 { + if url.as_str().len() > MAX_TARGET_BYTES { return Err("http_target_size_limit"); } Ok(url) @@ -875,6 +907,117 @@ mod tests { "/api/v1/records/42" ); } + #[test] + fn exploded_query_arrays_preserve_values_and_enforce_all_constraints() { + let mut schema = contract(); + schema["parameters"].as_array_mut().unwrap().push(json!({"name":"states","location":"query","required":false, + "schema":{"type":"array","items":{"type":"string","pattern":"^[a-z]+$","enum":["open","closed"]},"minItems":1,"maxItems":2,"uniqueItems":true}})); + let input = json!({"parameters":{"path":{"id":42},"query":{"states":["open","closed"]}}}); + let url = request_url(&connection(), &schema, &input).unwrap(); + assert_eq!( + url.query_pairs().collect::>(), + vec![ + ("states".into(), "open".into()), + ("states".into(), "closed".into()) + ] + ); + for values in [ + json!([]), + json!(["open", "open"]), + json!(["other"]), + json!(["open", "closed", "open"]), + json!([{}]), + json!("open"), + ] { + let mut invalid = input.clone(); + invalid["parameters"]["query"]["states"] = values; + assert_eq!( + request_url(&connection(), &schema, &invalid), + Err("http_parameter_schema_invalid") + ); + } + } + + #[test] + fn boolean_and_numeric_array_items_keep_their_wire_types() { + for (item_type, values, expected) in [ + ("boolean", json!([true, false]), vec!["true", "false"]), + ("integer", json!([1, 5]), vec!["1", "5"]), + ("number", json!([1.25, 2.5]), vec!["1.25", "2.5"]), + ] { + let mut schema = contract(); + schema["parameters"] + .as_array_mut() + .unwrap() + .push(json!({"name":"values","location":"query","required":true, + "schema":{"type":"array","items":{"type":item_type},"minItems":1}})); + let input = json!({"parameters":{"path":{"id":42},"query":{"values":values}}}); + let url = request_url(&connection(), &schema, &input).unwrap(); + assert_eq!( + url.query_pairs() + .map(|(_, value)| value.into_owned()) + .collect::>(), + expected + ); + } + } + + #[test] + fn array_items_are_encoded_separately_and_empty_required_arrays_cannot_disappear() { + let mut schema = contract(); + schema["parameters"] + .as_array_mut() + .unwrap() + .push(json!({"name":"values","location":"query","required":false, + "schema":{"type":"array","items":{"type":"string"}}})); + let mut input = + json!({"parameters":{"path":{"id":42},"query":{"values":["a&b 한글","x=y"]}}}); + let url = request_url(&connection(), &schema, &input).unwrap(); + assert_eq!( + url.query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(), + vec![ + ("values".into(), "a&b 한글".into()), + ("values".into(), "x=y".into()) + ] + ); + for values in [json!(["line\nbreak"]), json!(["x".repeat(2049)])] { + input["parameters"]["query"]["values"] = values; + assert_eq!( + request_url(&connection(), &schema, &input), + Err("http_parameter_invalid") + ); + } + input["parameters"]["query"]["values"] = json!([]); + assert!( + request_url(&connection(), &schema, &input) + .unwrap() + .query() + .is_none() + ); + schema["parameters"] + .as_array_mut() + .unwrap() + .last_mut() + .unwrap()["required"] = json!(true); + assert_eq!( + request_url(&connection(), &schema, &input), + Err("http_parameter_required") + ); + schema["parameters"] + .as_array_mut() + .unwrap() + .last_mut() + .unwrap()["required"] = json!(false); + input["parameters"]["query"]["values"] = + json!(["a&".repeat(1024), "a&".repeat(1024), "a&".repeat(1024)]); + assert_eq!( + request_url(&connection(), &schema, &input), + Err("http_target_size_limit") + ); + } + #[test] fn invalid_or_unknown_parameters_are_rejected_before_io() { for parameters in [ diff --git a/crates/xgen-cli/src/tool_graph_worker.py b/crates/xgen-cli/src/tool_graph_worker.py index ddefce3..1a7c8c7 100644 --- a/crates/xgen-cli/src/tool_graph_worker.py +++ b/crates/xgen-cli/src/tool_graph_worker.py @@ -494,11 +494,23 @@ def http_read_parameter(parameter, source): if "schema" not in parameter and source.get("swagger") == "2.0" and "type" in parameter: schema = {key: value for key, value in parameter.items() if key in ( "type", "enum", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", - "minLength", "maxLength", "pattern", "format", "default", "multipleOf")} + "minLength", "maxLength", "pattern", "format", "default", "multipleOf", + "items", "minItems", "maxItems", "uniqueItems")} if not isinstance(schema, (dict, bool)): raise WorkerError("http_original_contract_invalid") schema = dereference(schema, source) - if not isinstance(schema, dict) or schema.get("type") not in ("string", "integer", "number", "boolean"): + scalar_types = ("string", "integer", "number", "boolean") + array = isinstance(schema, dict) and schema.get("type") == "array" + if array: + items = schema.get("items") + if location != "query" or not isinstance(items, dict) or items.get("type") not in scalar_types or "prefixItems" in schema: + raise WorkerError("http_parameter_type_unsupported") + if source.get("swagger") == "2.0": + if parameter.get("collectionFormat", "csv") != "multi": + raise WorkerError("http_parameter_serialization_unsupported") + elif parameter.get("explode", True) is not True: + raise WorkerError("http_parameter_serialization_unsupported") + elif not isinstance(schema, dict) or schema.get("type") not in scalar_types: raise WorkerError("http_parameter_type_unsupported") if parameter.get("style", "simple" if location == "path" else "form") != ("simple" if location == "path" else "form") or parameter.get("allowReserved", False): raise WorkerError("http_parameter_serialization_unsupported") diff --git a/docs/adr/0082-array-query-parameters.md b/docs/adr/0082-array-query-parameters.md new file mode 100644 index 0000000..5bb3ab0 --- /dev/null +++ b/docs/adr/0082-array-query-parameters.md @@ -0,0 +1,47 @@ +# ADR-0082: query 배열은 scalar items와 반복 키 직렬화만 지원한다 + +- 상태: Accepted +- 기준일: 2026-10-31 +- 분류: 범용 HTTP 조회 확장 2단계. 도메인별 이름이나 판단을 사용하지 않는다. +- 후속 결정: ADR-0081의 파라미터별 지원 판정. + +## 결정 + +1. OpenAPI 3의 query `type: array` 중 `style: form`, `explode: true`만 허용한다. 두 값은 + query/form의 기본값도 인정한다. Swagger 2는 `collectionFormat: multi`만 허용한다. + 모델 입력은 JSON 배열이고 host가 `?states=open&states=closed`처럼 각 값을 별도 key/value로 쓴다. +2. `items`는 명시적인 string/integer/number/boolean schema여야 한다. 기존 scalar enum·pattern·범위와 + 배열의 minItems/maxItems/uniqueItems를 원본 전체 JSON Schema로 호출 전에 검증한다. + 항목별 길이·control character 제한과 전체 입력·URL 한도도 유지한다. URL 한도는 pair 추가 중에도 확인한다. +3. explode=false, spaceDelimited/pipeDelimited/deepObject, Swagger csv/ssv/tsv/pipes/default csv, + object·중첩 배열 items, tuple prefixItems와 path 배열은 지원하지 않는다. + 선택 항목이면 ADR-0081처럼 제외 이유를 표시하고, required/path이면 operation을 거절한다. +4. 빈 배열은 반복할 member가 없으므로 query pair를 만들지 않는다. 선택 배열은 이 표현을 허용하되, + required 배열이 wire에서 사라지는 것은 호출 전에 required 오류로 거절한다. minItems는 그와 별도로 적용한다. + 빈 문자열 하나를 가진 배열은 빈 배열과 다르며 기존 scalar 규칙을 따른다. +5. HTTP 실행 profile을 `xgen.http.read/v2`로 올리고 binding과 local execution profile이 같은 상수를 사용한다. + Capability 입력의 기존 path/query JSON object 형식과 `xgen.http/read@1.0.0`은 유지한다. + 이전 HTTP profile의 미완료 Run은 configuration_mismatch/execution_profile로 닫고 새 Run을 요구한다. + 완료된 Run의 offline replay는 추가 API·모델 요청 없이 유지한다. + +## 검증 + +- 설계: assets(OpenAPI 3)와 calendar(Swagger 2)에서 네 scalar items의 enum·pattern·범위 및 + minItems/maxItems/uniqueItems 보존, 지원 밖 serialization의 선택 제외·required 거절을 확인한다. +- host unit은 string·integer·number·boolean 반복 pair, Unicode·예약 문자 encoding, 개수·중복·enum· + 잘못된 items·control character·item/URL 한도와 빈 required 배열을 검사한다. +- 별도 shipment·sensor에서 import→실제 loopback HTTP를 실행하고 repeated query의 값 순서를 검사한다. + 잘못된 개수·중복·enum·숫자 범위는 HTTP 0회로 차단한다. 제외된 serialization 입력도 계속 거절한다. +- 이전 바이너리로 read 승인 대기 Run을 만든 뒤 새 바이너리로 resume하면 HTTP·모델 추가 호출 없이 + execution_profile drift를 거절한다. 이전 바이너리의 완료 Run은 새 바이너리에서 네트워크 없이 재생된다. +- 외부 커머스 API와 실제 모델 품질 비교는 이번 설계·검증에 포함하지 않는다. + +## 근거 + +[OpenAPI Parameter serialization](https://swagger.io/docs/specification/v3_0/serialization/)과 +[RFC 6570 §2.3](https://www.rfc-editor.org/rfc/rfc6570#section-2.3)의 composite value expansion을 따른다. +빈 list는 undefined로 취급되므로 required 파라미터의 wire presence를 별도로 보존한다. + +로컬 검증: Rust 781 passed·4 ignored, system Python venv+graph-tool-call0.47.0+컴파일 CLI 전체 +144개 검사(7 skipped) 통과. 이전 HTTP v1 바이너리를 주입한 두 실제 migration 검사도 실행했다. +Clippy·fmt·공개 문서·workflow 검사 통과. 유료 모델·외부 운영 API 호출은 없다. diff --git a/docs/development/http-read-tools.md b/docs/development/http-read-tools.md index e8c9513..0e64971 100644 --- a/docs/development/http-read-tools.md +++ b/docs/development/http-read-tools.md @@ -54,9 +54,14 @@ agent loop에는 연결이 있을 때 `xgen.http/read@1.0.0`이 추가된다. `http_read.excluded_parameters`에 이름 preview·위치·고정 이유를 표시하고 count/digest/complete를 함께 준다. 제외한 이름을 입력에 넣으면 HTTP 호출 전에 거절한다. required와 path 파라미터가 지원 밖이면 operation 전체를 거절한다. [ADR-0081](../adr/0081-optional-http-parameters.md). +- query 배열은 OpenAPI 3 `style=form, explode=true`(기본값 포함)와 Swagger 2 + `collectionFormat=multi`만 지원한다. JSON 배열을 반복 query key로 직렬화하며 items의 scalar 제약과 + minItems/maxItems/uniqueItems를 검증한다. 빈 선택 배열은 pair를 만들지 않고 빈 required 배열은 거절한다. + 다른 직렬화·object items·path 배열은 선택 제외/필수 거절 대상이다. + [실행 profile v2와 이전 Run 재개](../adr/0082-array-query-parameters.md). - scalar path/query: string/integer/number/boolean, simple path·form query, 원본 required·enum·pattern·수치/길이 제약. 값은 URL library로 encode한다. path의 dot segment·slash·역슬래시·percent 입력과 혼합 template는 거절한다. - 명시적인 숫자 2xx 응답의 `application/json` 스키마. 실제 응답도 해당 Content-Type이어야 한다. 순환 없는 문서 내부 `$ref`를 해제한다. -- 인증 없음 또는 OpenAPI HTTP bearer. OAuth·API key·cookie·header parameter·본문·배열 파라미터·외부/순환 ref·ref sibling·nullable/legacy boolean exclusive bound·dynamic ref는 호출 전에 거절한다. JSON Schema 2020-12에 호환되는 스키마만 실행한다. format은 annotation이며 추가 format 보증은 없다. +- 인증 없음 또는 OpenAPI HTTP bearer. OAuth·API key·cookie·header parameter·본문·지원 밖 배열 파라미터·외부/순환 ref·ref sibling·nullable/legacy boolean exclusive bound·dynamic ref는 호출 전에 거절한다. JSON Schema 2020-12에 호환되는 스키마만 실행한다. format은 annotation이며 추가 format 보증은 없다. - 기존 collection과 URL/Swagger UI로 직접 수집한 collection에는 실행에 필요한 원본이 없으므로 검색은 가능하지만 HTTP 실행은 거절한다. 실행하려면 로컬 JSON으로 새 이름에 다시 가져온다. 원격 수집 원본 보존은 graph-tool-call 연계 후속이다. - HTTPS, 또는 테스트용 숫자 loopback HTTP만 연결. redirect·환경 proxy·애플리케이션 재시도 없음. 응답 헤더 16 KiB, body 32 KiB, 전체 관찰 64 KiB, 입력·URL 각 8 KiB, HTTP timeout 30초. worker 계약 조회는 기존 offline 120초 한도다. - 인증은 호스트에서만 추가한다. bearer 값이 본문에 그대로 또는 JSON escape 해제 후 포함되면 본문을 버린다. 인증값·오류 원문·응답 헤더는 worker나 모델 컨텍스트·Receipt에 넣지 않는다. 일반 업무 데이터 전체에 대한 redaction 기능은 아니다. diff --git a/docs/development/tool-integration-todo.md b/docs/development/tool-integration-todo.md index ae0f519..a7ccb19 100644 --- a/docs/development/tool-integration-todo.md +++ b/docs/development/tool-integration-todo.md @@ -138,9 +138,9 @@ 비교 실행기는 해당 저장소 담당자의 작업이다. xgen 엔진에는 시스템별 규칙을 넣지 않는다. 1. **구현·로컬 검증:** 지원 밖 선택 파라미터 제외, required/path 거절, describe 제외 이유, 제외 입력 호출 전 거절. - [ADR-0081](../adr/0081-optional-http-parameters.md). 단독 PR로 먼저 제공한다. -2. **다음:** OpenAPI 3 form/explode=true와 Swagger 2 multi 배열 query, scalar items 및 배열 제약 검증. - 다른 직렬화·object items는 1번의 제외/거절 규칙을 따른다. + [ADR-0081](../adr/0081-optional-http-parameters.md), [PR #139](https://github.com/PlateerLab/xgen-cli/pull/139)로 main에 머지했다. +2. **구현·로컬 검증 완료:** OpenAPI 3 form/explode=true와 Swagger 2 multi 배열 query, scalar items 및 배열 제약 검증. + 다른 직렬화·object items는 1번의 제외/거절 규칙을 따른다. [ADR-0082](../adr/0082-array-query-parameters.md). 3. **이후:** 연결별 반복 `--read-operation`으로 호스트가 지정한 POST 조회, JSON body 원본 schema 검증, 지정 목록의 연결·execution profile 고정과 drift 거절. 지정하지 않은 POST는 거절한다. 4. **이후:** run/resume의 `--bearer-env-for CONNECTION=ENV_NAME`으로 저장된 연결을 바꾸지 않고 diff --git a/scripts/tests/test_http_read_tools.py b/scripts/tests/test_http_read_tools.py index e44f7a5..85b1285 100644 --- a/scripts/tests/test_http_read_tools.py +++ b/scripts/tests/test_http_read_tools.py @@ -12,6 +12,7 @@ import tempfile import threading import unittest +import urllib.parse import test_tool_graph_worker as base from test_tool_graph_worker import WORKER, asset_spec, calendar_spec @@ -46,7 +47,7 @@ def test_optional_unsupported_parameters_preserve_independent_contracts(self): for source, path in [(asset_spec(), "/assets"), (calendar_spec(), "/events")]: with self.subTest(path=path): parameters = [{"name":"keep","in":"query","type":"string","schema":{"type":"string"}}, - {"name":"filters","in":"query","type":"array","schema":{"type":"array","items":{"type":"string"}}}, + {"name":"filters","in":"query","type":"array","explode":False,"schema":{"type":"array","items":{"type":"string"}}}, {"name":"trace","in":"header","schema":{"type":"string"}}] source["paths"][path]["get"]["parameters"] = parameters result, tool = self.describe(source, path) @@ -54,7 +55,7 @@ def test_optional_unsupported_parameters_preserve_independent_contracts(self): contract = result["contract"] self.assertEqual([p["name"] for p in contract["parameters"]], ["keep"]) self.assertEqual(contract["excluded_parameters"], [ - {"location":"query","name":"filters","reason":"http_parameter_type_unsupported"}, + {"location":"query","name":"filters","reason":"http_parameter_serialization_unsupported"}, {"location":"header","name":"trace","reason":"http_parameter_location_unsupported"}]) self.assertEqual(result["contract_digest"], WORKER.digest(contract)) view = WORKER.describe_view({"collection":"fixture","artifact_digest":"a"*64,"artifact":{"tools":[tool],"metadata":{"source_count":1}}}, tool, result, 0) @@ -65,7 +66,7 @@ def test_optional_unsupported_parameters_preserve_independent_contracts(self): def test_required_shapes_and_path_parameters_still_close_the_operation(self): for location, required in [("query",True),("header",True),("path",False)]: source = asset_spec() - source["paths"]["/assets"]["get"]["parameters"] = [{"name":"bad","in":location,"required":required,"schema":{"type":"array","items":{"type":"string"}}}] + source["paths"]["/assets"]["get"]["parameters"] = [{"name":"bad","in":location,"required":required,"explode":False,"schema":{"type":"array","items":{"type":"string"}}}] result, _ = self.describe(source) self.assertFalse(result["supported"], result) @@ -150,6 +151,52 @@ def test_optional_serialization_and_dialect_are_reported_but_security_is_not_dro self.assertEqual(result, {"supported":False,"error":"http_security_unsupported"}) +class ArrayQueryContractTests(unittest.TestCase): + describe = staticmethod(OptionalParameterContractTests.describe) + + def test_form_explode_and_swagger_multi_keep_complete_item_and_array_constraints(self): + for source, path in [(asset_spec(), "/assets"), (calendar_spec(), "/events")]: + for items in [{"type":"string","enum":["open","closed"],"pattern":"^[a-z]+$"}, + {"type":"integer","minimum":1,"maximum":5}, + {"type":"number","minimum":0.1}, {"type":"boolean"}]: + with self.subTest(path=path, items=items): + schema = {"type":"array","items":items,"minItems":1,"maxItems":3,"uniqueItems":True} + parameter = {"name":"values","in":"query","required":True,"schema":schema} + if source.get("swagger"): + parameter = {"name":"values","in":"query","required":True, + "collectionFormat":"multi",**schema} + source["paths"][path]["get"]["parameters"] = [parameter] + result, _ = self.describe(source, path) + self.assertTrue(result["supported"], result) + self.assertEqual(result["contract"]["parameters"][0]["schema"],schema) + self.assertNotIn("excluded_parameters", result["contract"]) + + def test_other_array_serializations_and_object_items_follow_optional_required_policy(self): + for fields, reason in [({"explode":False}, "http_parameter_serialization_unsupported"), + *[({"style":style}, "http_parameter_serialization_unsupported") + for style in ["spaceDelimited","pipeDelimited","deepObject"]], + ({"schema":{"type":"array","items":{"type":"object"}}}, "http_parameter_type_unsupported")]: + for required in [False, True]: + source = asset_spec() + parameter = {"name":"values","in":"query","required":required, + "schema":{"type":"array","items":{"type":"string"}},**fields} + source["paths"]["/assets"]["get"]["parameters"] = [parameter] + result, _ = self.describe(source) + if required: + self.assertEqual(result,{"supported":False,"error":reason}) + else: + self.assertTrue(result["supported"],result) + self.assertEqual(result["contract"]["excluded_parameters"][0]["reason"],reason) + for encoding in [None,"csv","ssv","tsv","pipes"]: + source = calendar_spec() + parameter = {"name":"values","in":"query","type":"array","items":{"type":"integer"}} + if encoding is not None: parameter["collectionFormat"] = encoding + source["paths"]["/events"]["get"]["parameters"] = [parameter] + result, _ = self.describe(source,"/events") + self.assertTrue(result["supported"],result) + self.assertEqual(result["contract"]["excluded_parameters"][0]["reason"],"http_parameter_serialization_unsupported") + + @unittest.skipUnless(importlib.util.find_spec("graph_tool_call"),"run with pinned graph-tool-call environment") class ContractTests(unittest.TestCase): setUp = base.WorkerTests.setUp @@ -178,7 +225,7 @@ def test_unsupported_contracts_are_not_invented(self): op=source["paths"]["/assets"]["get"] if kind=="post": source["paths"]["/assets"]={"post":op} elif kind=="header": op["parameters"]=[{"name":"x","in":"header","required":True,"schema":{"type":"string"}}] - elif kind=="array": op["parameters"]=[{"name":"x","in":"query","required":True,"schema":{"type":"array","items":{"type":"string"}}}] + elif kind=="array": op["parameters"]=[{"name":"x","in":"query","required":True,"explode":False,"schema":{"type":"array","items":{"type":"string"}}}] elif kind=="xml": op["responses"]["200"]["content"]={"application/xml":{"schema":{"type":"object"}}} else: source["components"]={"schemas":{"Recursive":{"$ref":"#/components/schemas/Recursive"}}} @@ -248,8 +295,8 @@ def cleanup(): server.shutdown();server.server_close();thread.join(timeout=5) self.addCleanup(cleanup) return server - def cli(self,*args,check=True): - result=subprocess.run([os.environ["XGEN_TOOL_TEST_BINARY"],*args],env=self.env,capture_output=True,timeout=150) + def cli(self,*args,check=True,binary=None): + result=subprocess.run([binary or os.environ["XGEN_TOOL_TEST_BINARY"],*args],env=self.env,capture_output=True,timeout=150) if check: self.assertEqual(result.returncode,0,result.stderr.decode()) self.assertNotIn(self.secret.encode(),result.stdout+result.stderr) return result @@ -275,10 +322,10 @@ def plan(key,capability,args): def context(payload): return json.loads(next(m["content"] for m in payload["messages"] if m["role"]=="user"))["planningContext"] - def run_cli(self,allow=True): + def run_cli(self,allow=True,binary=None): args=["run","--workspace",str(self.workspace),"--base-url",f"http://127.0.0.1:{self.model.server_port}/v1","--model","fixture-model","--tokenizer","fixture-tokenizer","--allow-dir",".","--allow-remote-model-egress"] if allow: args += ["--allow-read"] - return self.cli(*args,"Read connected fixture API.",check=False) + return self.cli(*args,"Read connected fixture API.",check=False,binary=binary) def manifest(self): return json.loads(next((self.state/"runs").glob("*/manifest.json")).read_text()) @@ -321,22 +368,22 @@ def complete(payload): for path in self.state.rglob("*"): if path.is_file(): self.assertNotIn(self.secret.encode(),path.read_bytes(),str(path)) - def test_optional_array_is_hidden_and_injected_input_is_rejected_before_http(self): + def test_unsupported_optional_array_is_hidden_and_injected_input_is_rejected_before_http(self): for name, path, tool in [("shipments","/shipments/{tracking}","readShipment"), ("sensors","/sensors/{sensorId}","readSensor")]: with self.subTest(name=name): source = held_out_spec(name) - parameter = {"name":"states","in":"query","required":False, + parameter = {"name":"states","in":"query","required":False,"explode":False, "schema":{"type":"array","items":{"type":"string"}}} if name == "sensors": - parameter = {"name":"states","in":"query","required":False, + parameter = {"name":"states","in":"query","required":False,"collectionFormat":"csv", "type":"array","items":{"type":"integer"}} source["paths"][path]["get"]["parameters"].append(parameter) arguments = self.install(name, source) view = json.loads(self.cli("tools","describe","--name",name,tool).stdout) self.assertNotIn("states", [row["name"] for row in view["http_read"]["contract"]["parameters"]]) self.assertEqual(view["http_read"]["contract"]["excluded_parameters"][0]["name"], "states") - self.assertEqual(view["http_read"]["contract"]["excluded_parameters"][0]["reason"], "http_parameter_type_unsupported") + self.assertEqual(view["http_read"]["contract"]["excluded_parameters"][0]["reason"], "http_parameter_serialization_unsupported") self.response = {"tracking":"A42","delivered":True} if name == "shipments" else {"values":[2.5]} self.calls.clear(); self.requests.clear() self.proposals = [self.plan("describe", "xgen.tools/describe", {"collection":name,"tool":tool}), @@ -356,6 +403,62 @@ def test_optional_array_is_hidden_and_injected_input_is_rejected_before_http(sel self.assertNotEqual(result.returncode, 0) self.assertEqual(self.calls, []) + def test_repeated_query_pairs_and_invalid_array_inputs_on_independent_sources(self): + for name, path in [("shipments","/shipments/{tracking}"),("sensors","/sensors/{sensorId}")]: + with self.subTest(name=name): + self.state=self.directory/(name+"-arrays-state");self.env["XGEN_STATE_HOME"]=str(self.state) + source=held_out_spec(name) + schema={"type":"array","items":{"type":"string","enum":["open","closed"]}, + "minItems":1,"maxItems":2,"uniqueItems":True} + parameter={"name":"states","in":"query","schema":schema} + values=["open","closed"] + invalid=[[],["open","open"],["unknown"],["open","closed","open"]] + if name=="sensors": + schema={"type":"array","items":{"type":"integer","minimum":1,"maximum":5}, + "minItems":1,"maxItems":2,"uniqueItems":True} + parameter={"name":"states","in":"query","collectionFormat":"multi",**schema} + values=[1,5];invalid=[[],[1,1],[0],[6],[1,2,3]] + source["paths"][path]["get"]["parameters"].append(parameter) + arguments=self.install(name,source) + arguments["parameters"]["query"]["states"]=values + self.response={"tracking":"A42","delivered":True} if name=="shipments" else {"values":[2.5]} + self.calls.clear();self.requests.clear() + self.proposals=[self.plan("read","xgen.http/read",arguments)] + result=self.run_cli();self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertEqual(len(self.calls),1) + pairs=urllib.parse.parse_qsl(urllib.parse.urlsplit(self.calls[0][0]).query) + self.assertEqual([value for key,value in pairs if key=="states"],[str(value) for value in values]) + for bad_value in invalid: + self.calls.clear();self.requests.clear() + bad=copy.deepcopy(arguments);bad["parameters"]["query"]["states"]=bad_value + self.proposals=[self.plan("bad","xgen.http/read",bad)] + result=self.run_cli();self.assertNotEqual(result.returncode,0) + self.assertEqual(self.calls,[]) + + @unittest.skipUnless(os.environ.get("XGEN_HTTP_V1_TEST_BINARY"),"previous HTTP profile binary opt-in") + def test_previous_http_profile_does_not_resume_under_new_wire_rules(self): + arguments=self.install();self.proposals=[self.plan("read","xgen.http/read",arguments)] + paused=self.run_cli(allow=False,binary=os.environ["XGEN_HTTP_V1_TEST_BINARY"]) + self.assertEqual(paused.returncode,10,paused.stderr.decode()) + run_id=self.manifest()["record"]["runId"] + result=self.cli("resume",run_id,"--workspace",str(self.workspace),"--allow-dir",".", + "--base-url",f"http://127.0.0.1:{self.model.server_port}/v1","--allow-read",check=False) + self.assertEqual(result.returncode,64,result.stderr.decode()) + self.assertIn(b"configuration=execution_profile",result.stderr) + self.assertEqual(self.calls,[]) + self.assertEqual(len(self.requests),1) + + @unittest.skipUnless(os.environ.get("XGEN_HTTP_V1_TEST_BINARY"),"previous HTTP profile binary opt-in") + def test_completed_previous_http_profile_replays_without_network(self): + arguments=self.install();self.proposals=[self.plan("read","xgen.http/read",arguments)] + completed=self.run_cli(binary=os.environ["XGEN_HTTP_V1_TEST_BINARY"]) + self.assertEqual(completed.returncode,0,completed.stderr.decode()) + calls=len(self.calls);requests=len(self.requests) + result=self.cli("resume",self.manifest()["record"]["runId"],check=False) + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertEqual(len(self.calls),calls) + self.assertEqual(len(self.requests),requests) + def test_http_failures_do_not_publish_unverified_body_or_follow_redirect(self): arguments=self.install() cases=[(302,b"redirect"),(500,b"internal"),(200,b"not-json"),(200,{"tracking":42,"delivered":True}),(200,b"x"*32769),(200,{"tracking":self.secret,"delivered":True}), (200,json.dumps({"tracking":self.secret,"delivered":True}).replace(self.secret, "".join("\\u%04x" % ord(c) for c in self.secret)).encode())]