diff --git a/generators/cli/changes/unreleased/fix-multipart-nullable-binary-detection.yml b/generators/cli/changes/unreleased/fix-multipart-nullable-binary-detection.yml new file mode 100644 index 000000000000..568bd95a416f --- /dev/null +++ b/generators/cli/changes/unreleased/fix-multipart-nullable-binary-detection.yml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../../../../fern-changes-yml.schema.json + +- summary: | + Fix optional file uploads being sent as text parts. A `multipart/form-data` property + typed `anyOf: [{type: string, format: binary}, {type: "null"}]` — the shape every + optional file gets — was not recognized as a file, so the CLI sent the filename as a + plain text part and the API rejected the call (e.g. `422 Expected UploadFile, received: + `). File classification now unwraps `anyOf`/`oneOf` (resolving `$ref` + branches and ignoring the `null` branch), so nullable binary fields and nullable arrays + of binary fields upload the file contents. + type: fix diff --git a/generators/cli/sdk/src/openapi/parser.rs b/generators/cli/sdk/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/generators/cli/sdk/src/openapi/parser.rs +++ b/generators/cli/sdk/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/allof-inline/src/openapi/parser.rs b/seed/cli/allof-inline/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/allof-inline/src/openapi/parser.rs +++ b/seed/cli/allof-inline/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/allof/src/openapi/parser.rs b/seed/cli/allof/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/allof/src/openapi/parser.rs +++ b/seed/cli/allof/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs b/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs +++ b/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/file-upload-openapi/src/openapi/parser.rs b/seed/cli/file-upload-openapi/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/file-upload-openapi/src/openapi/parser.rs +++ b/seed/cli/file-upload-openapi/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/imdb/src/openapi/parser.rs b/seed/cli/imdb/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/imdb/src/openapi/parser.rs +++ b/seed/cli/imdb/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs b/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs +++ b/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/multi-content-type-examples/src/openapi/parser.rs b/seed/cli/multi-content-type-examples/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/multi-content-type-examples/src/openapi/parser.rs +++ b/seed/cli/multi-content-type-examples/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/multi-url-environment-reference/src/openapi/parser.rs b/seed/cli/multi-url-environment-reference/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/parser.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/no-content-response/src/openapi/parser.rs b/seed/cli/no-content-response/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/no-content-response/src/openapi/parser.rs +++ b/seed/cli/no-content-response/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/null-type/src/openapi/parser.rs b/seed/cli/null-type/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/null-type/src/openapi/parser.rs +++ b/seed/cli/null-type/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/nullable-allof-extends/src/openapi/parser.rs b/seed/cli/nullable-allof-extends/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/parser.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/nullable-request-body/src/openapi/parser.rs b/seed/cli/nullable-request-body/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/nullable-request-body/src/openapi/parser.rs +++ b/seed/cli/nullable-request-body/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/openapi-path-param-body-collision/src/openapi/parser.rs b/seed/cli/openapi-path-param-body-collision/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/openapi-path-param-body-collision/src/openapi/parser.rs +++ b/seed/cli/openapi-path-param-body-collision/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/openapi-request-body-ref/src/openapi/parser.rs b/seed/cli/openapi-request-body-ref/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/parser.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/openapi-subtitle/src/openapi/parser.rs b/seed/cli/openapi-subtitle/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/openapi-subtitle/src/openapi/parser.rs +++ b/seed/cli/openapi-subtitle/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/query-param-name-conflict/src/openapi/parser.rs b/seed/cli/query-param-name-conflict/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/parser.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs b/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/server-sent-events-openapi/src/openapi/parser.rs b/seed/cli/server-sent-events-openapi/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/parser.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/server-url-templating-single-url/src/openapi/parser.rs b/seed/cli/server-url-templating-single-url/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/server-url-templating-single-url/src/openapi/parser.rs +++ b/seed/cli/server-url-templating-single-url/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/server-url-templating/src/openapi/parser.rs b/seed/cli/server-url-templating/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/server-url-templating/src/openapi/parser.rs +++ b/seed/cli/server-url-templating/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/url-form-encoded/src/openapi/parser.rs b/seed/cli/url-form-encoded/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/url-form-encoded/src/openapi/parser.rs +++ b/seed/cli/url-form-encoded/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/webhook-audience/src/openapi/parser.rs b/seed/cli/webhook-audience/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/webhook-audience/src/openapi/parser.rs +++ b/seed/cli/webhook-audience/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/x-fern-default/src/openapi/parser.rs b/seed/cli/x-fern-default/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/x-fern-default/src/openapi/parser.rs +++ b/seed/cli/x-fern-default/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#" diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs index 067a3799ebfb..01da1716901e 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs @@ -3484,28 +3484,47 @@ fn classify_multipart_property( prop }; - let ty = resolved.schema_type(); - let fmt = resolved.format.as_deref(); + if is_binary_schema(resolved) { + return (true, Some("application/octet-stream".to_string())); + } - // `type: string, format: binary` or legacy `type: file` - if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { - let ct = Some("application/octet-stream".to_string()); - return (true, ct); + // Optional / nullable uploads wrap the binary schema in a composition: + // `anyOf: [{type: string, format: binary}, {type: "null"}]`. Unwrap the + // non-null branches (resolving `$ref`) and classify on those. + for branch in resolved.one_of.iter().chain(resolved.any_of.iter()) { + let effective = branch + .schema_ref + .as_ref() + .and_then(|r| component_schemas.get(&strip_ref_prefix(r))) + .unwrap_or(branch); + if is_null_sentinel(effective) { + continue; + } + if is_binary_schema(effective) { + return (true, Some("application/octet-stream".to_string())); + } + } + + (false, None) +} + +/// `true` when the schema is a binary payload: `type: string, format: binary`, +/// the legacy `type: file`, or an array whose items are either of those. +fn is_binary_schema(schema: &OpenApiSchemaObject) -> bool { + let ty = schema.schema_type(); + if (ty == Some("string") && schema.format.as_deref() == Some("binary")) || ty == Some("file") { + return true; } - // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) if ty == Some("array") { - if let Some(items) = &resolved.items { - if (items.schema_type() == Some("string") + if let Some(items) = &schema.items { + return (items.schema_type() == Some("string") && items.format.as_deref() == Some("binary")) - || items.schema_type() == Some("file") - { - return (true, Some("application/octet-stream".to_string())); - } + || items.schema_type() == Some("file"); } } - (false, None) + false } /// Recursively walk an object schema and emit one body-located [`MethodParameter`] @@ -4502,6 +4521,98 @@ paths: assert!(!purpose_field.required); } + #[test] + fn test_multipart_nullable_anyof_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /speech-to-text: + post: + x-fern-sdk-group-name: stt + x-fern-sdk-method-name: convert + operationId: sttConvert + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [model_id] + properties: + model_id: + type: string + file: + anyOf: + - type: string + format: binary + - type: "null" + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["stt"].methods["convert"]; + let file_field = convert + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!( + file_field.is_file, + "anyOf[binary, null] must be classified as a file part" + ); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream") + ); + assert!(!file_field.required); + } + + #[test] + fn test_multipart_nullable_oneof_ref_binary_is_file_part() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /dubbing: + post: + x-fern-sdk-group-name: dubbing + x-fern-sdk-method-name: create + operationId: dubbingCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + oneOf: + - $ref: '#/components/schemas/Upload' + - type: "null" + clips: + anyOf: + - type: array + items: { type: string, format: binary } + - type: "null" + responses: { "200": { description: ok } } +components: + schemas: + Upload: + type: string + format: binary +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["dubbing"].methods["create"]; + for name in ["csv_file", "clips"] { + let field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == name) + .unwrap_or_else(|| panic!("{name} field missing")); + assert!(field.is_file, "{name} must be classified as a file part"); + } + } + #[test] fn test_multipart_form_data_with_ref_schema() { let yaml = r#"