Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 검사를 통과했다.
2 changes: 1 addition & 1 deletion crates/xgen-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/xgen-cli/src/graph_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
};
Expand Down
155 changes: 149 additions & 6 deletions crates/xgen-cli/src/http_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Connection>;
const MAX_BODY: usize = 32 * 1024;
const MAX_TARGET_BYTES: usize = 8 * 1024;

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -353,6 +355,30 @@ fn scalar(value: &Value) -> Result<String, &'static str> {
}
}

fn parameter_values(value: &Value, definition: &Value) -> Result<Vec<String>, &'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,
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<_>>(),
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::<Vec<_>>(),
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<_>>(),
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 [
Expand Down
16 changes: 14 additions & 2 deletions crates/xgen-cli/src/tool_graph_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions docs/adr/0082-array-query-parameters.md
Original file line number Diff line number Diff line change
@@ -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 호출은 없다.
Loading